From 42e21c8ee94a5543c1faadc61308fa820903afbf Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Wed, 5 Mar 2025 18:30:41 +0100 Subject: [PATCH 01/18] fix key warning --- src/components/comment-content/comment-content.tsx | 10 ++++++---- src/components/post-desktop/post-desktop.tsx | 9 +++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/components/comment-content/comment-content.tsx b/src/components/comment-content/comment-content.tsx index 3450f05b..38168554 100644 --- a/src/components/comment-content/comment-content.tsx +++ b/src/components/comment-content/comment-content.tsx @@ -75,7 +75,7 @@ const CommentContent = ({ comment: post, replies }: { comment: Comment; replies:

- setShowFullComment(true)} /> }} /> + setShowFullComment(true)} /> }} />
)} {edit && original?.content !== content && ( @@ -87,20 +87,22 @@ const CommentContent = ({ comment: post, replies }: { comment: Comment; replies: i18nKey={'comment_edited_at_timestamp'} values={{ timestamp: getFormattedDate(edit?.timestamp) }} shouldUnescape={true} - components={{ 1: } /> }} + components={{ + 1: } />, + }} />{' '} {reason && <>{t('reason_reason', { reason: reason, interpolation: { escapeValue: false } })} } {showOriginal ? ( setShowOriginal(!showOriginal)} /> }} + components={{ 1: setShowOriginal(!showOriginal)} /> }} /> ) : ( setShowOriginal(!showOriginal)} /> }} + components={{ 1: setShowOriginal(!showOriginal)} /> }} /> )} diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index 1cc2718a..b9ca7826 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -433,11 +433,16 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, replies: }} + components={{ 1: }} values={{ repliesCount, linksCount }} /> ) : ( - }} values={{ repliesCount }} /> + }} + values={{ repliesCount }} + /> )} )} From 876af71e65fdc1dc430f35b69ee3f9995ed01c5d Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Wed, 5 Mar 2025 21:38:01 +0100 Subject: [PATCH 02/18] implement useSubplebbitsPagesStore instead of prop from useReplies --- .../comment-content/comment-content.tsx | 9 +++--- src/components/post-desktop/post-desktop.tsx | 21 ++++++------- src/components/post-mobile/post-mobile.tsx | 17 +++++----- .../reply-quote-preview.tsx | 31 ++++++------------- src/views/post/post.tsx | 8 ++--- 5 files changed, 35 insertions(+), 51 deletions(-) diff --git a/src/components/comment-content/comment-content.tsx b/src/components/comment-content/comment-content.tsx index 38168554..3a955788 100644 --- a/src/components/comment-content/comment-content.tsx +++ b/src/components/comment-content/comment-content.tsx @@ -2,6 +2,7 @@ import { Fragment, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import { Comment } from '@plebbit/plebbit-react-hooks'; +import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages'; import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils'; import { isPostPageView } from '../../lib/utils/view-utils'; import useIsMobile from '../../hooks/use-is-mobile'; @@ -13,7 +14,7 @@ import Tooltip from '../../components/tooltip'; import styles from '../../views/post/post.module.css'; import _ from 'lodash'; -const CommentContent = ({ comment: post, replies }: { comment: Comment; replies: Comment[] }) => { +const CommentContent = ({ comment: post }: { comment: Comment }) => { const { t } = useTranslation(); const params = useParams(); const location = useLocation(); @@ -34,7 +35,7 @@ const CommentContent = ({ comment: post, replies }: { comment: Comment; replies: ? content.slice(0, 2000) : content); - const quotelinkReply = parentCid && replies?.find((reply) => reply.cid === parentCid); + const quotelinkReply = useSubplebbitsPagesStore((state) => state.comments[parentCid]); const isReply = !!parentCid; const isReplyingToReply = isReply && parentCid !== postCid; @@ -45,9 +46,7 @@ const CommentContent = ({ comment: post, replies }: { comment: Comment; replies: return (
- {isReply && state !== 'failed' && isReplyingToReply && !(deleted || removed) && ( - - )} + {isReply && state !== 'failed' && isReplyingToReply && !(deleted || removed) && } {removed ? ( reason ? ( <> diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index b9ca7826..47bb8882 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -48,7 +48,7 @@ const useShowOmittedReplies = create((set) => ({ })), })); -const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, replies: threadReplies }: PostProps) => { +const PostInfo = ({ post, postReplyCount = 0, roles, isHidden }: PostProps) => { const { t } = useTranslation(); const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, shortCid, state, subplebbitAddress, timestamp } = post || {}; const title = post?.title?.trim(); @@ -219,7 +219,7 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, replies: threadRe (reply: Comment, index: number) => reply?.parentCid === cid && reply?.cid && - !(reply?.deleted || reply?.removed) && , + !(reply?.deleted || reply?.removed) && , )} @@ -306,7 +306,7 @@ const PostMedia = ({ commentMediaInfo, hasThumbnail, isDescription, isRules, spo ); }; -const Reply = ({ postReplyCount, reply, roles, replies }: PostProps) => { +const Reply = ({ postReplyCount, reply, roles }: PostProps) => { let post = reply; // handle pending mod or author edit const { editedComment } = useEditedComment({ comment: reply }); @@ -327,7 +327,7 @@ const Reply = ({ postReplyCount, reply, roles, replies }: PostProps) => {
{'>>'}
- + {link && !hidden && !(deleted || removed) && isValidURL(link) && ( { parentCid={parentCid} /> )} - {!hidden && (!(removed || deleted) || ((removed || deleted) && reason)) && } + {!hidden && (!(removed || deleted) || ((removed || deleted) && reason)) && }
); }; -const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, replies: threadReplies }: PostProps) => { +const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostProps) => { const { t } = useTranslation(); const { author, cid, content, deleted, link, linkHeight, linkWidth, pinned, postCid, removed, spoiler, state, subplebbitAddress, thumbnailUrl, parentCid } = post || {}; const { isDescription, isRules } = post || {}; // custom properties, not from api @@ -360,8 +360,7 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, replies: const { hidden, unhide, hide } = useHide({ cid }); const isHidden = hidden && !isInPostPageView; - const commentReplies = useReplies(post); - const replies = threadReplies || commentReplies; + const replies = useReplies(post); const visiblelinksCount = useCountLinksInReplies(post, 5); const totalLinksCount = useCountLinksInReplies(post); const replyCount = replies?.length; @@ -417,9 +416,9 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, replies: parentCid={parentCid} /> )} - + {!isHidden && !content && !(deleted || removed) &&
} - {!isHidden && } + {!isHidden && }
{!isHidden && !isDescription && !isRules && !isInPendingPostView && (replyCount > 5 || (pinned && repliesCount > 0)) && !isInPostPageView && ( @@ -453,7 +452,7 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, replies: showReplies && (showAllReplies || showOmittedReplies[cid] ? replies : replies.slice(-5)).map((reply, index) => (
- +
))} {isDescription && subplebbit?.rules && subplebbit?.rules.length > 0 && ( diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index 6541a6d0..95c09aa1 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -233,7 +233,7 @@ const PostMediaContent = ({ post, link }: { post: any; link: string }) => { ); }; -const ReplyBacklinks = ({ post, replies: threadReplies }: PostProps) => { +const ReplyBacklinks = ({ post }: PostProps) => { const { cid, parentCid } = post || {}; const replies = useReplies(post); @@ -246,14 +246,14 @@ const ReplyBacklinks = ({ post, replies: threadReplies }: PostProps) => { (reply: Comment, index: number) => reply?.parentCid === cid && reply?.cid && - !(reply?.deleted || reply?.removed) && , + !(reply?.deleted || reply?.removed) && , )} ) ); }; -const Reply = ({ postReplyCount, reply, replies, roles }: PostProps) => { +const Reply = ({ postReplyCount, reply, roles }: PostProps) => { let post = reply; // handle pending mod or author edit const { editedComment } = useEditedComment({ comment: reply }); @@ -274,7 +274,7 @@ const Reply = ({ postReplyCount, reply, replies, roles }: PostProps) => { data-post-cid={postCid} > - {!hidden && (!(removed || deleted) || ((removed || deleted) && reason)) && } + {!hidden && (!(removed || deleted) || ((removed || deleted) && reason)) && } @@ -282,7 +282,7 @@ const Reply = ({ postReplyCount, reply, replies, roles }: PostProps) => { ); }; -const PostMobile = ({ post, roles, showAllReplies, showReplies = true, replies: threadReplies }: PostProps) => { +const PostMobile = ({ post, roles, showAllReplies, showReplies = true }: PostProps) => { const { t } = useTranslation(); const { author, cid, pinned, postCid, replyCount, state, subplebbitAddress } = post || {}; const { isDescription, isRules } = post || {}; // custom properties, not from api @@ -292,8 +292,7 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true, replies: const isInPendingPostView = isPendingPostView(location.pathname, params); const isInPostView = isPostPageView(location.pathname, params); const linksCount = useCountLinksInReplies(post); - const commentReplies = useReplies(post); - const replies = threadReplies || commentReplies; + const replies = useReplies(post); const isInPostPageView = isPostPageView(location.pathname, params); const { hidden, unhide } = useHide({ cid }); @@ -339,7 +338,7 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true, replies: > {shouldShowSnow() && } - + {!isInPostView && !isInPendingPostView && showReplies && (
@@ -362,7 +361,7 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true, replies: showReplies && (showAllReplies ? replies : replies.slice(-5)).map((reply, index) => (
- +
))} {showRules && ( diff --git a/src/components/reply-quote-preview/reply-quote-preview.tsx b/src/components/reply-quote-preview/reply-quote-preview.tsx index 0d68b05f..c0fa61d1 100644 --- a/src/components/reply-quote-preview/reply-quote-preview.tsx +++ b/src/components/reply-quote-preview/reply-quote-preview.tsx @@ -13,7 +13,6 @@ interface ReplyQuotePreviewProps { backlinkReply?: Comment; isQuotelinkReply?: boolean; quotelinkReply?: Comment; - replies?: Comment[]; } const handleQuoteHover = (cid: string, onElementOutOfView: () => void) => { @@ -54,7 +53,7 @@ const handleQuoteHover = (cid: string, onElementOutOfView: () => void) => { } }; -const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, replies }: ReplyQuotePreviewProps) => { +const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply }: ReplyQuotePreviewProps) => { const [hoveredCid, setHoveredCid] = useState(null); const [outOfViewCid, setOutOfViewCid] = useState(null); const placementRef = useRef('right'); @@ -145,7 +144,7 @@ const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, i outOfViewCid === backlinkReply?.cid && createPortal(
- +
, document.body, )} @@ -174,7 +173,7 @@ const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, i outOfViewCid === quotelinkReply?.cid && createPortal(
- +
, document.body, )} @@ -184,7 +183,7 @@ const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, i return isBacklinkReply ? replyBacklink : isQuotelinkReply && replyQuotelink; }; -const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, replies }: ReplyQuotePreviewProps) => { +const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply }: ReplyQuotePreviewProps) => { const [hoveredCid, setHoveredCid] = useState(null); const [outOfViewCid, setOutOfViewCid] = useState(null); @@ -257,7 +256,7 @@ const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, is outOfViewCid === backlinkReply?.cid && createPortal(
- +
, document.body, )} @@ -293,7 +292,7 @@ const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, is outOfViewCid === quotelinkReply?.cid && createPortal(
- +
, document.body, )} @@ -303,25 +302,13 @@ const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, is return isBacklinkReply ? replyBacklink : isQuotelinkReply && replyQuotelink; }; -const ReplyQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, replies }: ReplyQuotePreviewProps) => { +const ReplyQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply }: ReplyQuotePreviewProps) => { const isMobile = useIsMobile(); return isMobile ? ( - + ) : ( - + ); }; diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index 3a12fd1d..f56f0158 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -10,6 +10,7 @@ import PostMobile from '../../components/post-mobile'; import SubplebbitDescription from '../../components/subplebbit-description'; import SubplebbitRules from '../../components/subplebbit-rules'; import styles from './post.module.css'; + export interface PostProps { index?: number; isHidden?: boolean; @@ -20,10 +21,9 @@ export interface PostProps { roles?: Role[]; showAllReplies?: boolean; showReplies?: boolean; - replies?: Comment[]; } -export const Post = ({ post, showAllReplies = false, showReplies = true, replies }: PostProps) => { +export const Post = ({ post, showAllReplies = false, showReplies = true }: PostProps) => { const subplebbit = useSubplebbitsStore((state) => state.subplebbits[post?.subplebbitAddress]); const isMobile = useIsMobile(); @@ -39,9 +39,9 @@ export const Post = ({ post, showAllReplies = false, showReplies = true, replies
{isMobile ? ( - + ) : ( - + )}
From 64c857fb9d383c3ee08cec114acc11181b91d4f5 Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Wed, 5 Mar 2025 22:05:20 +0100 Subject: [PATCH 03/18] chore(package.json): upgrade plebbit-react-hooks --- package.json | 2 +- src/views/board/board.tsx | 23 ++++++------ src/views/catalog/catalog.tsx | 23 ++++++------ yarn.lock | 68 +++++++++++++++++++++-------------- 4 files changed, 69 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index 84d0096a..12552d55 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dependencies": { "@capacitor/app": "6.0.1", "@floating-ui/react": "0.26.1", - "@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#6d35eb3b4dc84f8fdcbb46d95e3b1d78f1750b5f", + "@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#54cf36c50dd31f5641a9475f5566b7b29b3873c7", "@react-spring/web": "9.7.5", "@testing-library/jest-dom": "5.14.1", "@testing-library/react": "13.0.0", diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx index 9f7472f6..fbdc2252 100644 --- a/src/views/board/board.tsx +++ b/src/views/board/board.tsx @@ -20,13 +20,16 @@ import { Post } from '../post'; const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; -const threadsWithoutImagesFilter = (comment: Comment) => { - const { link, linkHeight, linkWidth, thumbnailUrl } = comment || {}; - if (!getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link)) { - return false; - } - return true; -}; +const createThreadsWithoutImagesFilter = () => ({ + filter: (comment: Comment) => { + const { link, linkHeight, linkWidth, thumbnailUrl } = comment || {}; + if (!getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link)) { + return false; + } + return true; + }, + key: 'threads-with-images-only', +}); const Board = () => { const { t } = useTranslation(); @@ -66,7 +69,7 @@ const Board = () => { sortType, postsPerPage: isInAllView || isInSubscriptionsView || isInModView ? 5 : 25, ...(isInAllView || isInSubscriptionsView || isInModView ? { newerThan: timeFilterSeconds } : {}), - filter: hideThreadsWithoutImages ? threadsWithoutImagesFilter : undefined, + filter: hideThreadsWithoutImages ? createThreadsWithoutImagesFilter() : undefined, }; const { feed, hasMore, loadMore, reset, subplebbitAddressesWithNewerPosts } = useFeed(feedOptions); @@ -134,13 +137,13 @@ const Board = () => { subplebbitAddresses, sortType, newerThan: 60 * 60 * 24 * 7, - filter: hideThreadsWithoutImages ? threadsWithoutImagesFilter : undefined, + filter: hideThreadsWithoutImages ? createThreadsWithoutImagesFilter() : undefined, }); const { feed: monthlyFeed } = useFeed({ subplebbitAddresses, sortType, newerThan: 60 * 60 * 24 * 30, - filter: hideThreadsWithoutImages ? threadsWithoutImagesFilter : undefined, + filter: hideThreadsWithoutImages ? createThreadsWithoutImagesFilter() : undefined, }); const feedLength = feed.length; diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx index 0beb6d24..09f0f2e5 100644 --- a/src/views/catalog/catalog.tsx +++ b/src/views/catalog/catalog.tsx @@ -20,13 +20,16 @@ import styles from './catalog.module.css'; const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; -const threadsWithoutImagesFilter = (comment: Comment) => { - const { link, linkHeight, linkWidth, thumbnailUrl } = comment || {}; - if (!getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link)) { - return false; - } - return true; -}; +const createThreadsWithoutImagesFilter = () => ({ + filter: (comment: Comment) => { + const { link, linkHeight, linkWidth, thumbnailUrl } = comment || {}; + if (!getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link)) { + return false; + } + return true; + }, + key: 'threads-with-images-only', +}); const Catalog = () => { const { t } = useTranslation(); @@ -79,7 +82,7 @@ const Catalog = () => { subplebbitAddresses, sortType, postsPerPage: isInAllView || isInSubscriptionsView ? 10 : postsPerPage, - filter: hideThreadsWithoutImages ? threadsWithoutImagesFilter : undefined, + filter: hideThreadsWithoutImages ? createThreadsWithoutImagesFilter() : undefined, }; if (isInAllView || isInSubscriptionsView) { @@ -143,13 +146,13 @@ const Catalog = () => { subplebbitAddresses, sortType, newerThan: 60 * 60 * 24 * 7, - filter: hideThreadsWithoutImages ? threadsWithoutImagesFilter : undefined, + filter: hideThreadsWithoutImages ? createThreadsWithoutImagesFilter() : undefined, }); const { feed: monthlyFeed } = useFeed({ subplebbitAddresses, sortType, newerThan: 60 * 60 * 24 * 30, - filter: hideThreadsWithoutImages ? threadsWithoutImagesFilter : undefined, + filter: hideThreadsWithoutImages ? createThreadsWithoutImagesFilter() : undefined, }); const [showMorePostsSuggestion, setShowMorePostsSuggestion] = useState(false); diff --git a/yarn.lock b/yarn.lock index 42d0b932..6b251947 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3069,6 +3069,22 @@ dependencies: debug "4.3.4" +"@plebbit/plebbit-react-hooks@https://github.com/plebbit/plebbit-react-hooks.git#54cf36c50dd31f5641a9475f5566b7b29b3873c7": + version "0.0.1" + resolved "https://github.com/plebbit/plebbit-react-hooks.git#54cf36c50dd31f5641a9475f5566b7b29b3873c7" + dependencies: + "@plebbit/plebbit-js" "https://github.com/plebbit/plebbit-js.git#5ff48e9b9a906a098a51b474a52692eebfb6561d" + "@plebbit/plebbit-logger" "https://github.com/plebbit/plebbit-logger.git" + assert "2.0.0" + ethers "5.6.9" + localforage "1.10.0" + lodash.isequal "4.5.0" + memoizee "0.4.15" + quick-lru "5.1.1" + uint8arrays "3.1.1" + uuid "8.3.2" + zustand "4.0.0" + "@plebbit/plebbit-react-hooks@https://github.com/plebbit/plebbit-react-hooks.git#6d35eb3b4dc84f8fdcbb46d95e3b1d78f1750b5f": version "0.0.1" resolved "https://github.com/plebbit/plebbit-react-hooks.git#6d35eb3b4dc84f8fdcbb46d95e3b1d78f1750b5f" @@ -3976,6 +3992,21 @@ dependencies: "@types/node" "*" +"@typescript-eslint/eslint-plugin@8.26.0": + version "8.26.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz#7e880faf91f89471c30c141951e15f0eb3a0599e" + integrity sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "8.26.0" + "@typescript-eslint/type-utils" "8.26.0" + "@typescript-eslint/utils" "8.26.0" + "@typescript-eslint/visitor-keys" "8.26.0" + graphemer "^1.4.0" + ignore "^5.3.1" + natural-compare "^1.4.0" + ts-api-utils "^2.0.1" + "@typescript-eslint/eslint-plugin@^5.5.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz#aeef0328d172b9e37d9bab6dbc13b87ed88977db" @@ -3992,21 +4023,6 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/eslint-plugin@^8.26.0": - version "8.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz#7e880faf91f89471c30c141951e15f0eb3a0599e" - integrity sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q== - dependencies: - "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "8.26.0" - "@typescript-eslint/type-utils" "8.26.0" - "@typescript-eslint/utils" "8.26.0" - "@typescript-eslint/visitor-keys" "8.26.0" - graphemer "^1.4.0" - ignore "^5.3.1" - natural-compare "^1.4.0" - ts-api-utils "^2.0.1" - "@typescript-eslint/experimental-utils@^5.0.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-5.62.0.tgz#14559bf73383a308026b427a4a6129bae2146741" @@ -4014,6 +4030,17 @@ dependencies: "@typescript-eslint/utils" "5.62.0" +"@typescript-eslint/parser@8.26.0": + version "8.26.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.26.0.tgz#9b4d2198e89f64fb81e83167eedd89a827d843a9" + integrity sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA== + dependencies: + "@typescript-eslint/scope-manager" "8.26.0" + "@typescript-eslint/types" "8.26.0" + "@typescript-eslint/typescript-estree" "8.26.0" + "@typescript-eslint/visitor-keys" "8.26.0" + debug "^4.3.4" + "@typescript-eslint/parser@^5.5.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.62.0.tgz#1b63d082d849a2fcae8a569248fbe2ee1b8a56c7" @@ -4024,17 +4051,6 @@ "@typescript-eslint/typescript-estree" "5.62.0" debug "^4.3.4" -"@typescript-eslint/parser@^8.26.0": - version "8.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.26.0.tgz#9b4d2198e89f64fb81e83167eedd89a827d843a9" - integrity sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA== - dependencies: - "@typescript-eslint/scope-manager" "8.26.0" - "@typescript-eslint/types" "8.26.0" - "@typescript-eslint/typescript-estree" "8.26.0" - "@typescript-eslint/visitor-keys" "8.26.0" - debug "^4.3.4" - "@typescript-eslint/scope-manager@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz#d9457ccc6a0b8d6b37d0eb252a23022478c5460c" From b127876ce4798d34448940ad14e82f2b161a0db7 Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Thu, 6 Mar 2025 00:07:08 +0100 Subject: [PATCH 04/18] feat(catalog): add catalog filters --- public/translations/ar/default.json | 3 +- public/translations/bn/default.json | 3 +- public/translations/cs/default.json | 5 +- public/translations/da/default.json | 5 +- public/translations/de/default.json | 3 +- public/translations/el/default.json | 5 +- public/translations/en/default.json | 5 +- public/translations/es/default.json | 5 +- public/translations/fa/default.json | 3 +- public/translations/fi/default.json | 5 +- public/translations/fil/default.json | 5 +- public/translations/fr/default.json | 5 +- public/translations/he/default.json | 5 +- public/translations/hi/default.json | 3 +- public/translations/hu/default.json | 5 +- public/translations/id/default.json | 5 +- public/translations/it/default.json | 5 +- public/translations/ja/default.json | 3 +- public/translations/ko/default.json | 3 +- public/translations/mr/default.json | 3 +- public/translations/nl/default.json | 5 +- public/translations/no/default.json | 5 +- public/translations/pl/default.json | 5 +- public/translations/pt/default.json | 5 +- public/translations/ro/default.json | 5 +- public/translations/ru/default.json | 5 +- public/translations/sq/default.json | 5 +- public/translations/sv/default.json | 5 +- public/translations/te/default.json | 5 +- public/translations/th/default.json | 3 +- public/translations/tr/default.json | 5 +- public/translations/uk/default.json | 5 +- public/translations/ur/default.json | 5 +- public/translations/vi/default.json | 5 +- public/translations/zh/default.json | 3 +- .../board-buttons/board-buttons.tsx | 17 +- src/stores/use-catalog-filters-store.ts | 134 +++++++++------ .../catalog-filters.module.css | 29 +++- .../catalog-filters/catalog-filters.tsx | 75 +++++++-- src/views/catalog/catalog.tsx | 158 +++++++++++++++--- yarn.lock | 16 -- 41 files changed, 411 insertions(+), 173 deletions(-) diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index ebce90c7..c303f9a9 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -291,5 +291,6 @@ "this_thread_was_removed": "تم إزالة هذا الموضوع بواسطة مشرفي المنتدى", "this_reply_was_deleted": "تم حذف هذه الرد من قبل مؤلفه", "this_reply_was_removed": "تمت إزالة هذه الرد من قبل مشرفي المنتدى", - "not_mod_of_any_board": "أنت لست مشرفًا في أي لوحة." + "not_mod_of_any_board": "أنت لست مشرفًا في أي لوحة.", + "filters_and_highlights": "الفلاتر والإبرازات" } \ No newline at end of file diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index a4451490..db35d423 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -291,5 +291,6 @@ "this_thread_was_removed": "এই থ্রেডটি বোর্ড moderators দ্বারা মুছে ফেলা হয়েছে", "this_reply_was_deleted": "এই প্রতিক্রিয়া তার লেখক দ্বারা মুছে ফেলা হয়েছে", "this_reply_was_removed": "এই প্রতিক্রিয়া বোর্ড পরিচালকদের দ্বারা সরানো হয়েছে", - "not_mod_of_any_board": "আপনি কোনও বোর্ডের মড নন।" + "not_mod_of_any_board": "আপনি কোনও বোর্ডের মড নন।", + "filters_and_highlights": "ফিল্টার এবং হাইলাইট" } \ No newline at end of file diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index 4b1cecc8..8342b63c 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -228,7 +228,7 @@ "pending": "Čeká na schválení", "failed": "Nepodařilo se", "add": "Přidat", - "pattern": "Vzor", + "pattern": "vzor", "enable": "Povolit", "order": "Pořadí", "text": "Text", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Toto vlákno bylo odstraněno moderátory fóra", "this_reply_was_deleted": "Tato odpověď byla smazána autorem", "this_reply_was_removed": "Tato odpověď byla odstraněna moderátory fóra", - "not_mod_of_any_board": "Nejste moderátor žádné desky." + "not_mod_of_any_board": "Nejste moderátor žádné desky.", + "filters_and_highlights": "Filtry a zvýraznění" } \ No newline at end of file diff --git a/public/translations/da/default.json b/public/translations/da/default.json index dfaab88e..3011fcd0 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -228,7 +228,7 @@ "pending": "Afventer", "failed": "Mislykkedes", "add": "Tilføj", - "pattern": "Mønster", + "pattern": "mønster", "enable": "Aktiver", "order": "Rækkefølge", "text": "Tekst", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Denne tråd blev fjernet af boardmoderatorer", "this_reply_was_deleted": "Dette svar blev slettet af forfatteren", "this_reply_was_removed": "Dette svar blev fjernet af forummoderatorer", - "not_mod_of_any_board": "Du er ikke moderator for nogen tavler." + "not_mod_of_any_board": "Du er ikke moderator for nogen tavler.", + "filters_and_highlights": "Filtre og fremhævninger" } \ No newline at end of file diff --git a/public/translations/de/default.json b/public/translations/de/default.json index d2b1eab4..aa677e1e 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -291,5 +291,6 @@ "this_thread_was_removed": "Dieser Thread wurde von den Moderatoren des Boards entfernt", "this_reply_was_deleted": "Diese Antwort wurde vom Autor gelöscht", "this_reply_was_removed": "Diese Antwort wurde von den Forenmoderatoren entfernt", - "not_mod_of_any_board": "Du bist kein Moderator eines Boards." + "not_mod_of_any_board": "Du bist kein Moderator eines Boards.", + "filters_and_highlights": "Filter & Highlights" } \ No newline at end of file diff --git a/public/translations/el/default.json b/public/translations/el/default.json index fcf0fb0e..7ba81d49 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -228,7 +228,7 @@ "pending": "Σε εκκρεμότητα", "failed": "Απέτυχε", "add": "Προσθήκη", - "pattern": "Μοτίβο", + "pattern": "μοτίβο", "enable": "Ενεργοποιήστε", "order": "Σειρά", "text": "Κείμενο", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Αυτή η συζήτηση αφαιρέθηκε από τους διαχειριστές του φόρουμ", "this_reply_was_deleted": "Αυτή η απάντηση διαγράφηκε από τον συγγραφέα της", "this_reply_was_removed": "Η απάντηση αυτή αφαιρέθηκε από τους διαχειριστές του φόρουμ", - "not_mod_of_any_board": "Δεν είστε διαχειριστής σε καμία κοινότητα." + "not_mod_of_any_board": "Δεν είστε διαχειριστής σε καμία κοινότητα.", + "filters_and_highlights": "Φίλτρα και επισημάνσεις" } \ No newline at end of file diff --git a/public/translations/en/default.json b/public/translations/en/default.json index 84d51c92..5e2b97ba 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -228,7 +228,7 @@ "pending": "Pending", "failed": "Failed", "add": "Add", - "pattern": "Pattern", + "pattern": "pattern", "enable": "Enable", "order": "Order", "text": "Text", @@ -291,5 +291,6 @@ "this_thread_was_removed": "This thread was removed by board moderators", "this_reply_was_deleted": "This reply was deleted by its author", "this_reply_was_removed": "This reply was removed by board moderators", - "not_mod_of_any_board": "You are not a mod of any board." + "not_mod_of_any_board": "You are not a mod of any board.", + "filters_and_highlights": "Filters & Highlights" } \ No newline at end of file diff --git a/public/translations/es/default.json b/public/translations/es/default.json index 49807a11..ac2e4202 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -228,7 +228,7 @@ "pending": "Pendiente", "failed": "Fallido", "add": "Añadir", - "pattern": "Patrón", + "pattern": "patrón", "enable": "Habilitar", "order": "Orden", "text": "Texto", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Este hilo fue eliminado por los moderadores del foro", "this_reply_was_deleted": "Esta respuesta fue eliminada por su autor", "this_reply_was_removed": "Esta respuesta fue eliminada por los moderadores del foro", - "not_mod_of_any_board": "No eres moderador de ningún tablero." + "not_mod_of_any_board": "No eres moderador de ningún tablero.", + "filters_and_highlights": "Filtros y resaltar" } \ No newline at end of file diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index b9b8f812..d49a7da3 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -291,5 +291,6 @@ "this_thread_was_removed": "این تاپیک توسط مدیران انجمن حذف شد", "this_reply_was_deleted": "این پاسخ توسط نویسنده‌اش حذف شد", "this_reply_was_removed": "این پاسخ توسط مدیران انجمن حذف شد", - "not_mod_of_any_board": "شما هیچ تابلو مدیر نیستید." + "not_mod_of_any_board": "شما هیچ تابلو مدیر نیستید.", + "filters_and_highlights": "فیلترها و هایلایت‌ها" } \ No newline at end of file diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index 2cba44fa..529bcb6c 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -228,7 +228,7 @@ "pending": "Odottaa", "failed": "Epäonnistui", "add": "Lisää", - "pattern": "Malli", + "pattern": "malli", "enable": "Ota käyttöön", "order": "Järjestys", "text": "Teksti", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Tämä ketju poistettiin foorumin moderaattoreiden toimesta", "this_reply_was_deleted": "Tämä vastaus poistettiin sen kirjoittajalta", "this_reply_was_removed": "Tämä vastaus poistettiin foorumin moderaattoreiden toimesta", - "not_mod_of_any_board": "Et ole moderaattori missään taulussa." + "not_mod_of_any_board": "Et ole moderaattori missään taulussa.", + "filters_and_highlights": "Suodattimet ja korostukset" } \ No newline at end of file diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index 1fde0af6..2b38665d 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -228,7 +228,7 @@ "pending": "Naghihintay", "failed": "Nabigo", "add": "Idagdag", - "pattern": "Pattern", + "pattern": "pattern", "enable": "I-enable", "order": "Kaayusan", "text": "Teksto", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Inalis ang thread na ito ng mga moderator ng board", "this_reply_was_deleted": "Ang tugon na ito ay tinanggal ng may-akda nito", "this_reply_was_removed": "Ang sagot na ito ay tinanggal ng mga moderator ng board", - "not_mod_of_any_board": "Hindi ka mod ng anumang board." + "not_mod_of_any_board": "Hindi ka mod ng anumang board.", + "filters_and_highlights": "Mga filter at highlight" } \ No newline at end of file diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index 890413c5..2a823f4f 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -228,7 +228,7 @@ "pending": "En attente", "failed": "Échoué", "add": "Ajouter", - "pattern": "Modèle", + "pattern": "motif", "enable": "Activer", "order": "Ordre", "text": "Texte", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Ce sujet a été supprimé par les modérateurs du forum", "this_reply_was_deleted": "Cette réponse a été supprimée par son auteur", "this_reply_was_removed": "Cette réponse a été supprimée par les modérateurs du forum", - "not_mod_of_any_board": "Vous n'êtes pas modérateur de n'aucun forum." + "not_mod_of_any_board": "Vous n'êtes pas modérateur de n'aucun forum.", + "filters_and_highlights": "Filtres et mises en évidence" } \ No newline at end of file diff --git a/public/translations/he/default.json b/public/translations/he/default.json index 117f53bb..fd493968 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -228,7 +228,7 @@ "pending": "ממתין", "failed": "נכשל", "add": "הוסף", - "pattern": "תבנית", + "pattern": "דפוס", "enable": "הפעל", "order": "סדר", "text": "טקסט", @@ -291,5 +291,6 @@ "this_thread_was_removed": "השרשור הזה הוסר על ידי מנהלי הפורום", "this_reply_was_deleted": "תשובה זו נמחקה על ידי המחבר שלה", "this_reply_was_removed": "תשובה זו הוסרה על ידי מנהלי הפורום", - "not_mod_of_any_board": "אתה לא מוד בכל לוח." + "not_mod_of_any_board": "אתה לא מוד בכל לוח.", + "filters_and_highlights": "סינונים והדגשות" } \ No newline at end of file diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index bdd422a4..2b659d1c 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -291,5 +291,6 @@ "this_thread_was_removed": "यह थ्रेड बोर्ड मॉडरेटर्स द्वारा हटा दिया गया था", "this_reply_was_deleted": "इस उत्तर को इसके लेखक द्वारा हटा दिया गया था", "this_reply_was_removed": "यह उत्तर बोर्ड मॉडरेटर द्वारा हटा दिया गया था", - "not_mod_of_any_board": "आप किसी बोर्ड के मॉड नहीं हैं।" + "not_mod_of_any_board": "आप किसी बोर्ड के मॉड नहीं हैं।", + "filters_and_highlights": "फ़िल्टर और हाइलाइट्स" } \ No newline at end of file diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index 7affabb6..325ebb3d 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -228,7 +228,7 @@ "pending": "Függőben", "failed": "Sikertelen", "add": "Hozzáad", - "pattern": "Minta", + "pattern": "minta", "enable": "Engedélyez", "order": "Rendelés", "text": "Szöveg", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Ezt a témát a fórum moderátorai eltávolították", "this_reply_was_deleted": "Ez a válasz törölve lett a szerzője által", "this_reply_was_removed": "Ez a válasz törlésre került a fórum moderátorai által", - "not_mod_of_any_board": "Nem vagy moderátor egyetlen táblán sem." + "not_mod_of_any_board": "Nem vagy moderátor egyetlen táblán sem.", + "filters_and_highlights": "Szűrők és kiemelések" } \ No newline at end of file diff --git a/public/translations/id/default.json b/public/translations/id/default.json index 65240c4c..6e344b03 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -228,7 +228,7 @@ "pending": "Tertunda", "failed": "Gagal", "add": "Tambahkan", - "pattern": "Pola", + "pattern": "pola", "enable": "Aktifkan", "order": "Urutan", "text": "Teks", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Thread ini dihapus oleh moderator papan", "this_reply_was_deleted": "Balasan ini dihapus oleh penulisnya", "this_reply_was_removed": "Balasan ini dihapus oleh moderator papan", - "not_mod_of_any_board": "Anda bukan mod di papan manapun." + "not_mod_of_any_board": "Anda bukan mod di papan manapun.", + "filters_and_highlights": "Filter dan Sorotan" } \ No newline at end of file diff --git a/public/translations/it/default.json b/public/translations/it/default.json index 4352987e..cf016b54 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -228,7 +228,7 @@ "pending": "In attesa", "failed": "Fallito", "add": "Aggiungi", - "pattern": "Modello", + "pattern": "modello", "enable": "Abilita", "order": "Ordine", "text": "Testo", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Questo thread è stato rimosso dai moderatori del board", "this_reply_was_deleted": "Questa risposta è stata eliminata dal suo autore", "this_reply_was_removed": "Questa risposta è stata rimossa dai moderatori del forum", - "not_mod_of_any_board": "Non sei un mod di nessuna board." + "not_mod_of_any_board": "Non sei un mod di nessuna board.", + "filters_and_highlights": "Filtri e Evidenziazioni" } \ No newline at end of file diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index 1b225aea..eff04089 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -291,5 +291,6 @@ "this_thread_was_removed": "このスレッドはボードのモデレーターによって削除されました", "this_reply_was_deleted": "この返信はその著者によって削除されました", "this_reply_was_removed": "この返信は掲示板のモデレーターによって削除されました", - "not_mod_of_any_board": "あなたはどのボードのモデレーターでもありません。" + "not_mod_of_any_board": "あなたはどのボードのモデレーターでもありません。", + "filters_and_highlights": "フィルターとハイライト" } \ No newline at end of file diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index cabe38ac..8fcfdca8 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -291,5 +291,6 @@ "this_thread_was_removed": "이 스레드는 보드 관리자에 의해 제거되었습니다", "this_reply_was_deleted": "이 답글은 작성자에 의해 삭제되었습니다", "this_reply_was_removed": "이 답글은 게시판 관리자에 의해 삭제되었습니다", - "not_mod_of_any_board": "당신은 어떤 게시판의 관리자도 아닙니다." + "not_mod_of_any_board": "당신은 어떤 게시판의 관리자도 아닙니다.", + "filters_and_highlights": "필터 및 하이라이트" } \ No newline at end of file diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index e1fb115b..4d5d1a05 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -291,5 +291,6 @@ "this_thread_was_removed": "हे थ्रेड बोर्ड मध्यमाने काढले गेले आहे", "this_reply_was_deleted": "हा प्रतिसाद त्याच्या लेखकाने हटवला आहे", "this_reply_was_removed": "या उत्तराला बोर्ड मॉडरेटरने काढले आहे", - "not_mod_of_any_board": "तुम्ही कोणत्याही बोर्डचे मॉड नाहीत." + "not_mod_of_any_board": "तुम्ही कोणत्याही बोर्डचे मॉड नाहीत.", + "filters_and_highlights": "फिल्टर आणि हायलाइट्स" } \ No newline at end of file diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index 3a8ceb86..66e004d0 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -228,7 +228,7 @@ "pending": "In afwachting", "failed": "Mislukt", "add": "Toevoegen", - "pattern": "Patroon", + "pattern": "patroon", "enable": "Inschakelen", "order": "Volgorde", "text": "Tekst", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Deze thread werd verwijderd door boardmoderators", "this_reply_was_deleted": "Dit antwoord werd verwijderd door de auteur", "this_reply_was_removed": "Dit antwoord is verwijderd door de forum moderators", - "not_mod_of_any_board": "Je bent geen mod van een bord." + "not_mod_of_any_board": "Je bent geen mod van een bord.", + "filters_and_highlights": "Filters en markeringen" } \ No newline at end of file diff --git a/public/translations/no/default.json b/public/translations/no/default.json index a9f98e2f..957e4e5e 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -228,7 +228,7 @@ "pending": "Venter", "failed": "Feilet", "add": "Legg til", - "pattern": "Mønster", + "pattern": "mønster", "enable": "Aktiver", "order": "Rekkefølge", "text": "Tekst", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Denne tråden ble fjernet av boardmoderatorer", "this_reply_was_deleted": "Dette svaret ble slettet av forfatteren", "this_reply_was_removed": "Dette svaret ble fjernet av forummoderatorer", - "not_mod_of_any_board": "Du er ikke moderator på noen brett." + "not_mod_of_any_board": "Du er ikke moderator på noen brett.", + "filters_and_highlights": "Filtre og fremhevinger" } \ No newline at end of file diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index d3d35c42..a282392e 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -228,7 +228,7 @@ "pending": "Oczekujące", "failed": "Nieudane", "add": "Dodaj", - "pattern": "Wzór", + "pattern": "wzór", "enable": "Włącz", "order": "Kolejność", "text": "Tekst", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Wątek został usunięty przez moderatorów forum", "this_reply_was_deleted": "Ta odpowiedź została usunięta przez autora", "this_reply_was_removed": "Ta odpowiedź została usunięta przez moderatorów forum", - "not_mod_of_any_board": "Nie jesteś moderatorem żadnej tablicy." + "not_mod_of_any_board": "Nie jesteś moderatorem żadnej tablicy.", + "filters_and_highlights": "Filtry i wyróżnienia" } \ No newline at end of file diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index dc6c0523..7227a583 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -228,7 +228,7 @@ "pending": "Pendente", "failed": "Falhou", "add": "Adicionar", - "pattern": "Padrão", + "pattern": "padrão", "enable": "Ativar", "order": "Ordem", "text": "Texto", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Este tópico foi removido pelos moderadores do fórum", "this_reply_was_deleted": "Esta resposta foi excluída pelo autor", "this_reply_was_removed": "Esta resposta foi removida pelos moderadores do fórum", - "not_mod_of_any_board": "Você não é um moderador de nenhum quadro." + "not_mod_of_any_board": "Você não é um moderador de nenhum quadro.", + "filters_and_highlights": "Filtros e Destaques" } \ No newline at end of file diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index 456f1e4e..bb3afd18 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -228,7 +228,7 @@ "pending": "În așteptare", "failed": "Eșuat", "add": "Adaugă", - "pattern": "Model", + "pattern": "model", "enable": "Activare", "order": "Ordine", "text": "Text", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Acest thread a fost eliminat de moderatorii board-ului", "this_reply_was_deleted": "Acest răspuns a fost șters de autorul său", "this_reply_was_removed": "Acest răspuns a fost eliminat de moderatorii forumului", - "not_mod_of_any_board": "Nu ești moderator pe niciun tablou." + "not_mod_of_any_board": "Nu ești moderator pe niciun tablou.", + "filters_and_highlights": "Filtre și evidențiere" } \ No newline at end of file diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index fcec4b16..1845e5e9 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -228,7 +228,7 @@ "pending": "В ожидании", "failed": "Не удалось", "add": "Добавить", - "pattern": "Шаблон", + "pattern": "узор", "enable": "Включить", "order": "Порядок", "text": "Текст", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Эта тема была удалена модераторами форума", "this_reply_was_deleted": "Этот ответ был удален автором", "this_reply_was_removed": "Этот ответ был удален модераторами форума", - "not_mod_of_any_board": "Вы не модератор ни одной доски." + "not_mod_of_any_board": "Вы не модератор ни одной доски.", + "filters_and_highlights": "Фильтры и выделения" } \ No newline at end of file diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index 3dfd4993..30c031a8 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -228,7 +228,7 @@ "pending": "Në pritje", "failed": "Dështuar", "add": "Shto", - "pattern": "Model", + "pattern": "model", "enable": "Aktivizo", "order": "Rendit", "text": "Tekst", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Ky thread u hoq nga moderatorët e bordit", "this_reply_was_deleted": "Ky përgjigje u fshi nga autori i saj", "this_reply_was_removed": "Ky përgjigje u hoq nga moderatorët e forumit", - "not_mod_of_any_board": "Nuk jeni mod në asnjë bord." + "not_mod_of_any_board": "Nuk jeni mod në asnjë bord.", + "filters_and_highlights": "Filtra dhe theksime" } \ No newline at end of file diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index f2f34930..c3220d2c 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -228,7 +228,7 @@ "pending": "Väntande", "failed": "Misslyckades", "add": "Lägg till", - "pattern": "Mönster", + "pattern": "mönster", "enable": "Aktivera", "order": "Ordning", "text": "Text", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Denna tråd togs bort av forumets moderatorer", "this_reply_was_deleted": "Detta svar togs bort av författaren", "this_reply_was_removed": "Detta svar togs bort av forumets moderatorer", - "not_mod_of_any_board": "Du är inte moderator på någon tavla." + "not_mod_of_any_board": "Du är inte moderator på någon tavla.", + "filters_and_highlights": "Filter och markeringar" } \ No newline at end of file diff --git a/public/translations/te/default.json b/public/translations/te/default.json index b8cf78ec..77071d70 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -228,7 +228,7 @@ "pending": "అనుమతించబడాలి", "failed": "విజయవంతం కాలేదు", "add": "చేర్చండి", - "pattern": "ప్యాటర్న్", + "pattern": "పాటర్న్", "enable": "చేర్చు", "order": "ఆర్డర్", "text": "పాఠం", @@ -291,5 +291,6 @@ "this_thread_was_removed": "ఈ థ్రెడ్ బోర్డ్ మోడరేటర్ల ద్వారా తీసివేయబడింది", "this_reply_was_deleted": "ఈ స్పందనను దాని రచయిత తొలగించారు", "this_reply_was_removed": "ఈ స్పందన బోర్డ్ మోడరేటర్లచే తొలగించబడింది", - "not_mod_of_any_board": "మీరు ఎటువంటి బోర్డుకు మోడ్ కంటే లేరు." + "not_mod_of_any_board": "మీరు ఎటువంటి బోర్డుకు మోడ్ కంటే లేరు.", + "filters_and_highlights": "ఫిల్టర్లు మరియు హైలైట్‌లు" } \ No newline at end of file diff --git a/public/translations/th/default.json b/public/translations/th/default.json index a91107c7..424791e3 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -291,5 +291,6 @@ "this_thread_was_removed": "กระทู้นี้ถูกลบโดยผู้ดูแลบอร์ด", "this_reply_was_deleted": "การตอบกลับนี้ถูกลบโดยผู้แต่ง", "this_reply_was_removed": "คำตอบนี้ถูกลบโดยผู้ดูแลบอร์ด", - "not_mod_of_any_board": "คุณไม่ใช่ผู้ดูแลบอร์ดใดๆ" + "not_mod_of_any_board": "คุณไม่ใช่ผู้ดูแลบอร์ดใดๆ", + "filters_and_highlights": "ตัวกรองและไฮไลท์" } \ No newline at end of file diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index 1b338343..be858ec5 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -228,7 +228,7 @@ "pending": "Beklemede", "failed": "Başarısız", "add": "Ekle", - "pattern": "Desen", + "pattern": "desen", "enable": "Etkinleştir", "order": "Sıra", "text": "Metin", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Bu konu, board moderatörleri tarafından kaldırıldı", "this_reply_was_deleted": "Bu yanıt, yazar tarafından silindi", "this_reply_was_removed": "Bu yanıt, forum moderatörleri tarafından kaldırıldı", - "not_mod_of_any_board": "Hiçbir tahtanın moderatörü değilsiniz." + "not_mod_of_any_board": "Hiçbir tahtanın moderatörü değilsiniz.", + "filters_and_highlights": "Filtreler ve Vurgular" } \ No newline at end of file diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index f2849a7b..d4521437 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -228,7 +228,7 @@ "pending": "Очікує", "failed": "Не вдалося", "add": "Додати", - "pattern": "Шаблон", + "pattern": "шаблон", "enable": "Увімкнути", "order": "Порядок", "text": "Текст", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Цю тему було видалено модераторами форуму", "this_reply_was_deleted": "Цю відповідь було видалено її автором", "this_reply_was_removed": "Цю відповідь було видалено модераторами форуму", - "not_mod_of_any_board": "Ви не модератор жодної дошки." + "not_mod_of_any_board": "Ви не модератор жодної дошки.", + "filters_and_highlights": "Фільтри та виділення" } \ No newline at end of file diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index 8053751e..2100d08a 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -228,7 +228,7 @@ "pending": "منتظر", "failed": "ناکام", "add": "شامل کریں", - "pattern": "نمونہ", + "pattern": "پیٹرن", "enable": "فعال کریں", "order": "ترتیب", "text": "متن", @@ -291,5 +291,6 @@ "this_thread_was_removed": "اس تھریڈ کو بورڈ کے موڈریٹرز نے ہٹا دیا ہے", "this_reply_was_deleted": "اس جواب کو اس کے مصنف نے حذف کر دیا تھا", "this_reply_was_removed": "اس جواب کو بورڈ کے منتظمین نے حذف کر دیا ہے", - "not_mod_of_any_board": "آپ کسی بورڈ کے موڈ نہیں ہیں۔" + "not_mod_of_any_board": "آپ کسی بورڈ کے موڈ نہیں ہیں۔", + "filters_and_highlights": "فلٹرز اور ہائیلائٹس" } \ No newline at end of file diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index 89d4e137..acd7aed7 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -228,7 +228,7 @@ "pending": "Đang chờ", "failed": "Thất bại", "add": "Thêm", - "pattern": "Mẫu", + "pattern": "mẫu", "enable": "Kích hoạt", "order": "Thứ tự", "text": "Văn bản", @@ -291,5 +291,6 @@ "this_thread_was_removed": "Chủ đề này đã bị các quản trị viên diễn đàn xóa", "this_reply_was_deleted": "Câu trả lời này đã bị tác giả của nó xóa", "this_reply_was_removed": "Phản hồi này đã bị xóa bởi các quản trị viên diễn đàn", - "not_mod_of_any_board": "Bạn không phải là quản trị viên của bất kỳ bảng nào." + "not_mod_of_any_board": "Bạn không phải là quản trị viên của bất kỳ bảng nào.", + "filters_and_highlights": "Bộ lọc và Nổi bật" } \ No newline at end of file diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index e595957e..ead5e850 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -291,5 +291,6 @@ "this_thread_was_removed": "此帖子已被论坛版主删除", "this_reply_was_deleted": "该回复已被其作者删除", "this_reply_was_removed": "该回复已被版主删除", - "not_mod_of_any_board": "您不是任何板块的管理员。" + "not_mod_of_any_board": "您不是任何板块的管理员。", + "filters_and_highlights": "筛选器和高亮" } \ No newline at end of file diff --git a/src/components/board-buttons/board-buttons.tsx b/src/components/board-buttons/board-buttons.tsx index 5efa3ec5..d64121ae 100644 --- a/src/components/board-buttons/board-buttons.tsx +++ b/src/components/board-buttons/board-buttons.tsx @@ -13,7 +13,6 @@ import useCountLinksInReplies from '../../hooks/use-count-links-in-replies'; import _ from 'lodash'; import useIsMobile from '../../hooks/use-is-mobile'; import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; -import { useEffect } from 'react'; interface BoardButtonsProps { address?: string | undefined; @@ -244,13 +243,7 @@ export const MobileBoardButtons = () => { const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any }); const subplebbitAddress = params?.subplebbitAddress || accountComment?.subplebbitAddress; - const { filteredCount, resetFilteredCount } = useCatalogFiltersStore(); - - useEffect(() => { - if (subplebbitAddress) { - resetFilteredCount(); - } - }, [subplebbitAddress, resetFilteredCount]); + const { filteredCount } = useCatalogFiltersStore(); return (
@@ -334,13 +327,7 @@ export const DesktopBoardButtons = () => { const isInPostView = isPostPageView(location.pathname, params); const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); - const { filteredCount, resetFilteredCount } = useCatalogFiltersStore(); - - useEffect(() => { - if (subplebbitAddress) { - resetFilteredCount(); - } - }, [subplebbitAddress, resetFilteredCount]); + const { filteredCount } = useCatalogFiltersStore(); return ( <> diff --git a/src/stores/use-catalog-filters-store.ts b/src/stores/use-catalog-filters-store.ts index ef72762f..0d05f31b 100644 --- a/src/stores/use-catalog-filters-store.ts +++ b/src/stores/use-catalog-filters-store.ts @@ -1,11 +1,14 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { Comment } from '@plebbit/plebbit-react-hooks'; -import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils'; interface FilterItem { text: string; enabled: boolean; + count: number; + filteredCids: Set; + hide: boolean; + top: boolean; } interface CatalogFiltersStore { @@ -20,7 +23,9 @@ interface CatalogFiltersStore { updateFilter: () => void; initializeFilter: () => void; filteredCount: number; - resetFilteredCount: () => void; + filteredCids: Set; + incrementFilterCount: (filterIndex: number, cid: string) => void; + recalcFilteredCount: () => void; } const useCatalogFiltersStore = create( @@ -28,76 +33,109 @@ const useCatalogFiltersStore = create( (set, get) => ({ showTextOnlyThreads: false, setShowTextOnlyThreads: (value: boolean) => { - set({ showTextOnlyThreads: value, filteredCount: 0 }); + set({ showTextOnlyThreads: value }); get().updateFilter(); }, filterText: '', setFilterText: (value: string) => set({ filterText: value }), filterItems: [], + filteredCount: 0, + filteredCids: new Set(), setFilterItems: (items: FilterItem[]) => { - const nonEmptyItems = items.filter((item) => item.text.trim() !== ''); + const nonEmptyItems = items + .filter((item) => item.text.trim() !== '') + .map((item) => ({ + ...item, + count: item.count || 0, + filteredCids: item.filteredCids || new Set(), + hide: item.hide ?? true, + top: item.top ?? false, + })); set({ filterItems: nonEmptyItems }); + get().recalcFilteredCount(); }, saveAndApplyFilters: (items: FilterItem[]) => { - const nonEmptyItems = items.filter((item) => item.text.trim() !== ''); - set({ filterItems: nonEmptyItems, filteredCount: 0 }); + const nonEmptyItems = items + .filter((item) => item.text.trim() !== '') + .map((item) => ({ + ...item, + count: item.count || 0, + filteredCids: item.filteredCids || new Set(), + hide: item.hide ?? true, + top: item.top ?? false, + })); + set({ + filterItems: nonEmptyItems, + filteredCids: new Set(), + }); + get().recalcFilteredCount(); get().updateFilter(); }, filter: undefined, updateFilter: () => { - // const filteredCids = new Set(); set((state) => ({ filter: (comment: Comment) => { - const { - showTextOnlyThreads, - // filterItems - } = state; - - const { link, linkHeight, linkWidth, thumbnailUrl } = comment || {}; - - const hasThumbnail = getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link); - // const title = comment?.title?.toLowerCase() || ''; - // const content = comment?.content?.toLowerCase() || ''; - - // const matchesFilterItems = filterItems - // .filter((item) => item.enabled) - // .some((item) => { - // const text = item.text.toLowerCase(); - // return title.includes(text) || content.includes(text); - // }); - - const shouldShow = showTextOnlyThreads || hasThumbnail; - - if ( - !shouldShow - // || matchesFilterItems - ) { - // if ( - // matchesFilterItems && - // !filteredCids.has(comment.cid)) { - // filteredCids.add(comment.cid); - // set((state) => ({ filteredCount: state.filteredCount + 1 })); - // } - return false; + if (!comment?.cid) return true; + const { filterItems } = state; + let shouldHide = false; + for (let i = 0; i < filterItems.length; i++) { + const item = filterItems[i]; + if (item.enabled && item.text.trim() !== '') { + const pattern = item.text.toLowerCase(); + const title = comment?.title?.toLowerCase() || ''; + const content = comment?.content?.toLowerCase() || ''; + if (title.includes(pattern) || content.includes(pattern)) { + if (item.hide) { + shouldHide = true; + } + } + } } - - return true; + return !shouldHide; }, })); }, initializeFilter: () => { - const { updateFilter } = get(); - updateFilter(); + get().updateFilter(); + }, + incrementFilterCount: (filterIndex: number, cid: string) => { + set((state) => { + const newFilterItems = [...state.filterItems]; + if (newFilterItems[filterIndex]) { + const item = newFilterItems[filterIndex]; + if (!item.filteredCids.has(cid)) { + const newItemFilteredCids = new Set(item.filteredCids); + newItemFilteredCids.add(cid); + newFilterItems[filterIndex] = { + ...item, + count: item.count + 1, + filteredCids: newItemFilteredCids, + }; + const newFilteredCount = newFilterItems.reduce((sum, filt) => (filt.hide ? sum + filt.count : sum), 0); + return { filterItems: newFilterItems, filteredCount: newFilteredCount }; + } + } + return state; + }); + }, + recalcFilteredCount: () => { + set((state) => ({ + filteredCount: state.filterItems.reduce((sum, item) => (item.hide ? sum + item.count : sum), 0), + })); }, - filteredCount: 0, - resetFilteredCount: () => set({ filteredCount: 0 }), }), { name: 'catalog-filters-storage', - onRehydrateStorage: () => (state) => { - if (state) { - state.updateFilter(); - } + partialize: (state) => { + return { + showTextOnlyThreads: state.showTextOnlyThreads, + filterItems: state.filterItems.map((item) => ({ + text: item.text, + enabled: item.enabled, + hide: item.hide, + top: item.top, + })), + } as any; }, }, ), diff --git a/src/views/catalog/catalog-filters/catalog-filters.module.css b/src/views/catalog/catalog-filters/catalog-filters.module.css index e5039403..52aecd44 100644 --- a/src/views/catalog/catalog-filters/catalog-filters.module.css +++ b/src/views/catalog/catalog-filters/catalog-filters.module.css @@ -35,7 +35,7 @@ top: 60px; left: 50%; transform: translateX(-50%); - width: 340px; + width: 380px; height: auto; max-height: 80%; overflow-y: auto; @@ -51,6 +51,12 @@ border-left: var(--settings-modal-border-left); } +@media (max-width: 420px) { + .modal { + width: 90vw; + } +} + .header { font-size: 16px; font-weight: 700; @@ -142,7 +148,7 @@ outline: none; border: var(--reply-modal-field-input-border, revert); font-size: 11px; - width: 200px; + width: 150px; } .filtersTable input[type='text']:focus { @@ -164,3 +170,22 @@ .saveButton { float: right; } + +.filterHits { + font-size: 12px !important; + text-transform: none; +} + +.clickbox { + width: 16px; + height: 16px; + line-height: 16px; + font-size: 10px; + display: block; + text-align: center; + background-color: #fff; + border: 1px solid #aaa; + text-decoration: none; + color: #000; + margin: auto; +} \ No newline at end of file diff --git a/src/views/catalog/catalog-filters/catalog-filters.tsx b/src/views/catalog/catalog-filters/catalog-filters.tsx index e104f51e..fc403549 100644 --- a/src/views/catalog/catalog-filters/catalog-filters.tsx +++ b/src/views/catalog/catalog-filters/catalog-filters.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import useCatalogFiltersStore from '../../../stores/use-catalog-filters-store'; import styles from './catalog-filters.module.css'; @@ -7,16 +7,44 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { const { t } = useTranslation(); const { filterItems, saveAndApplyFilters } = useCatalogFiltersStore(); - const [localFilterItems, setLocalFilterItems] = useState(filterItems); + const [localFilterItems, setLocalFilterItems] = useState( + filterItems.map((item) => ({ + ...item, + hide: item.hide ?? true, + top: item.top ?? false, + })), + ); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + // Update local items when store changes + useEffect(() => { + setLocalFilterItems( + filterItems.map((item) => ({ + ...item, + hide: item.hide ?? true, + top: item.top ?? false, + })), + ); + }, [filterItems]); + const handleAddFilter = useCallback(() => { setLocalFilterItems((prev) => { const newIndex = prev.length; setTimeout(() => { inputRefs.current[newIndex]?.focus(); }, 0); - return [...prev, { text: '', enabled: true }]; + return [ + ...prev, + { + text: '', + enabled: true, + count: 0, + filteredCids: new Set(), + hide: true, + top: false, + }, + ]; }); }, []); @@ -47,10 +75,13 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { - - - - + + + + + + + @@ -80,17 +111,27 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { ref={(el) => (inputRefs.current[index] = el)} /> + + + + ))} - - + ))} diff --git a/src/stores/use-catalog-filters-store.ts b/src/stores/use-catalog-filters-store.ts index edb50748..c736b3b8 100644 --- a/src/stores/use-catalog-filters-store.ts +++ b/src/stores/use-catalog-filters-store.ts @@ -50,7 +50,50 @@ const useCatalogFiltersStore = create( filteredCount: 0, filteredCids: new Set(), currentSubplebbitAddress: null, - setCurrentSubplebbitAddress: (address: string | null) => set({ currentSubplebbitAddress: address }), + setCurrentSubplebbitAddress: (address: string | null) => { + const prevAddress = get().currentSubplebbitAddress; + + if (address !== prevAddress) { + set((state) => { + if (address) { + const updatedFilterItems = state.filterItems.map((item) => { + const newItem = { ...item }; + + // Ensure subplebbitCounts is a Map + if (!newItem.subplebbitCounts || !(newItem.subplebbitCounts instanceof Map)) { + newItem.subplebbitCounts = new Map(Array.isArray(newItem.subplebbitCounts) ? newItem.subplebbitCounts : []); + } + + // Ensure subplebbitFilteredCids is a Map + if (!newItem.subplebbitFilteredCids || !(newItem.subplebbitFilteredCids instanceof Map)) { + newItem.subplebbitFilteredCids = new Map(); + } + + // Now safely check if the address exists in the map + if (!newItem.subplebbitCounts.has(address)) { + newItem.subplebbitFilteredCids.set(address, new Set()); + newItem.subplebbitCounts.set(address, 0); + } + + return newItem; + }); + + return { + currentSubplebbitAddress: address, + filterItems: updatedFilterItems, + filteredCount: 0, + }; + } + + return { currentSubplebbitAddress: address }; + }); + + get().recalcFilteredCount(); + get().updateFilter(); + } else { + set({ currentSubplebbitAddress: address }); + } + }, searchText: '', setSearchFilter: (text: string) => { set({ searchText: text }); @@ -100,6 +143,9 @@ const useCatalogFiltersStore = create( filter: (comment: Comment) => { if (!comment?.cid) return true; + const currentSubplebbit = state.currentSubplebbitAddress; + + // Apply search filter if (state.searchText.trim() !== '') { const searchPattern = state.searchText.toLowerCase(); const title = comment?.title?.toLowerCase() || ''; @@ -110,21 +156,36 @@ const useCatalogFiltersStore = create( } } + // Apply content filters const { filterItems } = state; let shouldHide = false; + for (let i = 0; i < filterItems.length; i++) { const item = filterItems[i]; if (item.enabled && item.text.trim() !== '') { const pattern = item.text.toLowerCase(); const title = comment?.title?.toLowerCase() || ''; const content = comment?.content?.toLowerCase() || ''; + if (title.includes(pattern) || content.includes(pattern)) { + // If we have a current subplebbit and this is a match, increment the count + if (currentSubplebbit && comment.subplebbitAddress === currentSubplebbit) { + // We need to use a timeout to avoid modifying state during a state update + setTimeout(() => { + const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled); + if (filterIndex !== -1) { + get().incrementFilterCount(filterIndex, comment.cid, comment.subplebbitAddress); + } + }, 0); + } + if (item.hide) { shouldHide = true; } } } } + return !shouldHide; }, })); From ad6040479273327e95c1b13a373c368c8ad2ccd7 Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Thu, 6 Mar 2025 18:07:44 +0100 Subject: [PATCH 10/18] fix catalog filter counts persistence between subplebbits and page refreshes --- .../catalog-filters/catalog-filters.tsx | 14 +++++++ src/hooks/use-catalog-feed-rows.ts | 18 ++++++++- src/stores/use-catalog-filters-store.ts | 40 ++++++++++++++++--- src/views/catalog/catalog.tsx | 2 +- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/components/catalog-filters/catalog-filters.tsx b/src/components/catalog-filters/catalog-filters.tsx index d64e69d3..257efded 100644 --- a/src/components/catalog-filters/catalog-filters.tsx +++ b/src/components/catalog-filters/catalog-filters.tsx @@ -56,6 +56,20 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { onSave(); }, [saveAndApplyFilters, localFilterItems, onSave]); + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + handleSave(); + } + }, + [handleSave], + ); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); + const updateLocalFilterItem = useCallback((index: number, item: any) => { setLocalFilterItems((prev) => prev.map((f, i) => (i === index ? item : f))); }, []); diff --git a/src/hooks/use-catalog-feed-rows.ts b/src/hooks/use-catalog-feed-rows.ts index 6ab2b373..17b2cfb8 100644 --- a/src/hooks/use-catalog-feed-rows.ts +++ b/src/hooks/use-catalog-feed-rows.ts @@ -95,7 +95,23 @@ const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolea } return _feed; - }, [accountComments, feed, description, rules, address, isFeedLoaded, createdAt, title, shortAddress, avatarUrl, t, isInAllView, multisub, hideThreadsWithoutImages]); + }, [ + accountComments, + feed, + description, + rules, + address, + isFeedLoaded, + createdAt, + title, + shortAddress, + avatarUrl, + t, + isInAllView, + multisub, + hideThreadsWithoutImages, + searchText, + ]); const rows = useMemo(() => { const rows = []; diff --git a/src/stores/use-catalog-filters-store.ts b/src/stores/use-catalog-filters-store.ts index c736b3b8..f4f106bc 100644 --- a/src/stores/use-catalog-filters-store.ts +++ b/src/stores/use-catalog-filters-store.ts @@ -59,9 +59,13 @@ const useCatalogFiltersStore = create( const updatedFilterItems = state.filterItems.map((item) => { const newItem = { ...item }; + // Reset count and filteredCids (global counters) + newItem.count = 0; + newItem.filteredCids = new Set(); + // Ensure subplebbitCounts is a Map if (!newItem.subplebbitCounts || !(newItem.subplebbitCounts instanceof Map)) { - newItem.subplebbitCounts = new Map(Array.isArray(newItem.subplebbitCounts) ? newItem.subplebbitCounts : []); + newItem.subplebbitCounts = new Map(); } // Ensure subplebbitFilteredCids is a Map @@ -69,9 +73,11 @@ const useCatalogFiltersStore = create( newItem.subplebbitFilteredCids = new Map(); } - // Now safely check if the address exists in the map - if (!newItem.subplebbitCounts.has(address)) { + // Only initialize for the new address if it doesn't already exist + if (!newItem.subplebbitFilteredCids.has(address)) { newItem.subplebbitFilteredCids.set(address, new Set()); + } + if (!newItem.subplebbitCounts.has(address)) { newItem.subplebbitCounts.set(address, 0); } @@ -81,13 +87,14 @@ const useCatalogFiltersStore = create( return { currentSubplebbitAddress: address, filterItems: updatedFilterItems, - filteredCount: 0, + filteredCount: 0, // This will be recalculated below }; } return { currentSubplebbitAddress: address }; }); + // Recalculate the filtered count for the current subplebbit get().recalcFilteredCount(); get().updateFilter(); } else { @@ -199,12 +206,14 @@ const useCatalogFiltersStore = create( if (newFilterItems[filterIndex]) { const item = newFilterItems[filterIndex]; + // Ensure subplebbitFilteredCids is a Map const subplebbitFilteredCids = new Map(item.subplebbitFilteredCids); if (!subplebbitFilteredCids.has(subplebbitAddress)) { subplebbitFilteredCids.set(subplebbitAddress, new Set()); } const cidSet = subplebbitFilteredCids.get(subplebbitAddress)!; + // Only increment the count if this CID hasn't been counted for this subplebbit yet if (!cidSet.has(cid)) { const newItemFilteredCids = new Set(item.filteredCids); newItemFilteredCids.add(cid); @@ -274,11 +283,30 @@ const useCatalogFiltersStore = create( enabled: item.enabled, hide: item.hide, top: item.top, - subplebbitCounts: Array.from(item.subplebbitCounts || new Map()), - subplebbitFilteredCids: Array.from((item.subplebbitFilteredCids || new Map()).entries()).map(([key, value]) => [key, Array.from(value)]), })), } as any; }, + deserialize: (persisted) => { + const persistedObj = typeof persisted === 'string' ? JSON.parse(persisted) : persisted; + + if (persistedObj && persistedObj.filterItems) { + return { + ...persistedObj, + filterItems: persistedObj.filterItems.map((item: any) => ({ + text: item.text, + enabled: item.enabled, + hide: item.hide, + top: item.top, + count: 0, + filteredCids: new Set(), + subplebbitCounts: new Map(), + subplebbitFilteredCids: new Map>(), + })), + filteredCount: 0, + }; + } + return persistedObj || persisted; + }, }, ), ); diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx index a0c5bb9a..28a5d2f3 100644 --- a/src/views/catalog/catalog.tsx +++ b/src/views/catalog/catalog.tsx @@ -461,7 +461,7 @@ const Catalog = () => {

- {processedFeed.length !== 0 ? ( + {processedFeed?.length !== 0 ? ( <> Date: Thu, 6 Mar 2025 23:48:17 +0100 Subject: [PATCH 11/18] feat(catalog filter): add filtering with complex patterns, including regex, help modal --- .../{question-blue.png => help-blue.png} | Bin .../{question-dark.png => help-dark.png} | Bin .../{question-photon.png => help-photon.png} | Bin .../{question-red.png => help-red.png} | Bin public/translations/ar/default.json | 5 +- public/translations/bn/default.json | 5 +- public/translations/cs/default.json | 5 +- public/translations/da/default.json | 5 +- public/translations/de/default.json | 5 +- public/translations/el/default.json | 5 +- public/translations/en/default.json | 5 +- public/translations/es/default.json | 5 +- public/translations/fa/default.json | 5 +- public/translations/fi/default.json | 5 +- public/translations/fil/default.json | 5 +- public/translations/fr/default.json | 5 +- public/translations/he/default.json | 5 +- public/translations/hi/default.json | 5 +- public/translations/hu/default.json | 5 +- public/translations/id/default.json | 5 +- public/translations/it/default.json | 5 +- public/translations/ja/default.json | 5 +- public/translations/ko/default.json | 5 +- public/translations/mr/default.json | 5 +- public/translations/nl/default.json | 5 +- public/translations/no/default.json | 5 +- public/translations/pl/default.json | 5 +- public/translations/pt/default.json | 5 +- public/translations/ro/default.json | 5 +- public/translations/ru/default.json | 5 +- public/translations/sq/default.json | 5 +- public/translations/sv/default.json | 5 +- public/translations/te/default.json | 5 +- public/translations/th/default.json | 5 +- public/translations/tr/default.json | 5 +- public/translations/uk/default.json | 5 +- public/translations/ur/default.json | 5 +- public/translations/vi/default.json | 5 +- public/translations/zh/default.json | 5 +- .../catalog-filters.module.css | 78 ++++++++++-- .../catalog-filters/catalog-filters.tsx | 115 ++++++++++++++++-- src/lib/utils/pattern-utils.ts | 110 +++++++++++++++++ src/stores/use-catalog-filters-store.ts | 77 ++++++++++-- src/themes.css | 9 +- src/views/catalog/catalog.tsx | 21 +--- 45 files changed, 500 insertions(+), 85 deletions(-) rename public/assets/buttons/{question-blue.png => help-blue.png} (100%) rename public/assets/buttons/{question-dark.png => help-dark.png} (100%) rename public/assets/buttons/{question-photon.png => help-photon.png} (100%) rename public/assets/buttons/{question-red.png => help-red.png} (100%) create mode 100644 src/lib/utils/pattern-utils.ts diff --git a/public/assets/buttons/question-blue.png b/public/assets/buttons/help-blue.png similarity index 100% rename from public/assets/buttons/question-blue.png rename to public/assets/buttons/help-blue.png diff --git a/public/assets/buttons/question-dark.png b/public/assets/buttons/help-dark.png similarity index 100% rename from public/assets/buttons/question-dark.png rename to public/assets/buttons/help-dark.png diff --git a/public/assets/buttons/question-photon.png b/public/assets/buttons/help-photon.png similarity index 100% rename from public/assets/buttons/question-photon.png rename to public/assets/buttons/help-photon.png diff --git a/public/assets/buttons/question-red.png b/public/assets/buttons/help-red.png similarity index 100% rename from public/assets/buttons/question-red.png rename to public/assets/buttons/help-red.png diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index d1efbc9c..113e9c0e 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "تمت إزالة هذه الرد من قبل مشرفي المنتدى", "not_mod_of_any_board": "أنت لست مشرفًا في أي لوحة.", "filters_and_highlights": "الفلاتر والإبرازات", - "search_results_for": "نتائج البحث عن" + "search_results_for": "نتائج البحث عن", + "help": "مساعدة", + "filter_and_highlights_help": "مساعدة الفلاتر والإبرازات", + "filter_and_highlights": "التصفية والتسليط الضوء" } \ No newline at end of file diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index 83602f2a..16fef571 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "এই প্রতিক্রিয়া বোর্ড পরিচালকদের দ্বারা সরানো হয়েছে", "not_mod_of_any_board": "আপনি কোনও বোর্ডের মড নন।", "filters_and_highlights": "ফিল্টার এবং হাইলাইট", - "search_results_for": "অনুসন্ধান ফলাফল" + "search_results_for": "অনুসন্ধান ফলাফল", + "help": "সাহায্য", + "filter_and_highlights_help": "ফিল্টার এবং হাইলাইট সাহায্য", + "filter_and_highlights": "ফিল্টার এবং হাইলাইট" } \ No newline at end of file diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index 513d6992..70fabc09 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Tato odpověď byla odstraněna moderátory fóra", "not_mod_of_any_board": "Nejste moderátor žádné desky.", "filters_and_highlights": "Filtry a zvýraznění", - "search_results_for": "Výsledky hledání pro" + "search_results_for": "Výsledky hledání pro", + "help": "Pomoc", + "filter_and_highlights_help": "Pomoc s filtry a zvýrazněním", + "filter_and_highlights": "Filtry a zvýraznění" } \ No newline at end of file diff --git a/public/translations/da/default.json b/public/translations/da/default.json index 3def0138..daaa9a56 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Dette svar blev fjernet af forummoderatorer", "not_mod_of_any_board": "Du er ikke moderator for nogen tavler.", "filters_and_highlights": "Filtre og fremhævninger", - "search_results_for": "Søgeresultater for" + "search_results_for": "Søgeresultater for", + "help": "Hjælp", + "filter_and_highlights_help": "Filtre og fremhævelser Hjælp", + "filter_and_highlights": "Filtre og fremhævninger" } \ No newline at end of file diff --git a/public/translations/de/default.json b/public/translations/de/default.json index 9750d5a4..59b755c2 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Diese Antwort wurde von den Forenmoderatoren entfernt", "not_mod_of_any_board": "Du bist kein Moderator eines Boards.", "filters_and_highlights": "Filter & Highlights", - "search_results_for": "Suchergebnisse für" + "search_results_for": "Suchergebnisse für", + "help": "Hilfe", + "filter_and_highlights_help": "Filter- und Markierungs-Hilfe", + "filter_and_highlights": "Filter und Hervorhebungen" } \ No newline at end of file diff --git a/public/translations/el/default.json b/public/translations/el/default.json index 0a1c369c..2929b622 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Η απάντηση αυτή αφαιρέθηκε από τους διαχειριστές του φόρουμ", "not_mod_of_any_board": "Δεν είστε διαχειριστής σε καμία κοινότητα.", "filters_and_highlights": "Φίλτρα και επισημάνσεις", - "search_results_for": "Αποτελέσματα αναζήτησης για" + "search_results_for": "Αποτελέσματα αναζήτησης για", + "help": "Βοήθεια", + "filter_and_highlights_help": "Βοήθεια φίλτρων και επισήμανσης", + "filter_and_highlights": "Φίλτρα και Επισημάνσεις" } \ No newline at end of file diff --git a/public/translations/en/default.json b/public/translations/en/default.json index f17a13c5..0c9ee101 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "This reply was removed by board moderators", "not_mod_of_any_board": "You are not a mod of any board.", "filters_and_highlights": "Filters & Highlights", - "search_results_for": "Search results for" + "search_results_for": "Search results for", + "help": "Help", + "filter_and_highlights_help": "Filters & Highlights Help", + "filter_and_highlights": "Filters & Highlights" } \ No newline at end of file diff --git a/public/translations/es/default.json b/public/translations/es/default.json index ea303d00..9d1732fa 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Esta respuesta fue eliminada por los moderadores del foro", "not_mod_of_any_board": "No eres moderador de ningún tablero.", "filters_and_highlights": "Filtros y resaltar", - "search_results_for": "Resultados de búsqueda para" + "search_results_for": "Resultados de búsqueda para", + "help": "Ayuda", + "filter_and_highlights_help": "Ayuda de filtros y resaltes", + "filter_and_highlights": "Filtros y resaltados" } \ No newline at end of file diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index fb331102..704609a8 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "این پاسخ توسط مدیران انجمن حذف شد", "not_mod_of_any_board": "شما هیچ تابلو مدیر نیستید.", "filters_and_highlights": "فیلترها و هایلایت‌ها", - "search_results_for": "نتایج جستجو برای" + "search_results_for": "نتایج جستجو برای", + "help": "کمک", + "filter_and_highlights_help": "کمک فیلترها و برجسته‌ها", + "filter_and_highlights": "فیلترها و برجسته‌ها" } \ No newline at end of file diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index 2e66654c..f95e9ee9 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Tämä vastaus poistettiin foorumin moderaattoreiden toimesta", "not_mod_of_any_board": "Et ole moderaattori missään taulussa.", "filters_and_highlights": "Suodattimet ja korostukset", - "search_results_for": "Hakutulokset hakusanalle" + "search_results_for": "Hakutulokset hakusanalle", + "help": "Apua", + "filter_and_highlights_help": "Suodattimet ja korostukset -apu", + "filter_and_highlights": "Suodattimet ja korostukset" } \ No newline at end of file diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index d233af04..fe1679f5 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Ang sagot na ito ay tinanggal ng mga moderator ng board", "not_mod_of_any_board": "Hindi ka mod ng anumang board.", "filters_and_highlights": "Mga filter at highlight", - "search_results_for": "Mga resulta ng paghahanap para sa" + "search_results_for": "Mga resulta ng paghahanap para sa", + "help": "Tulong", + "filter_and_highlights_help": "Tulong sa mga filter at highlight", + "filter_and_highlights": "Mga filter at highlight" } \ No newline at end of file diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index 98b51f6a..d987ed39 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Cette réponse a été supprimée par les modérateurs du forum", "not_mod_of_any_board": "Vous n'êtes pas modérateur de n'aucun forum.", "filters_and_highlights": "Filtres et mises en évidence", - "search_results_for": "Résultats de recherche pour" + "search_results_for": "Résultats de recherche pour", + "help": "Aide", + "filter_and_highlights_help": "Aide sur les filtres et surlignages", + "filter_and_highlights": "Filtres et surlignages" } \ No newline at end of file diff --git a/public/translations/he/default.json b/public/translations/he/default.json index 337312cc..728a16d2 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "תשובה זו הוסרה על ידי מנהלי הפורום", "not_mod_of_any_board": "אתה לא מוד בכל לוח.", "filters_and_highlights": "סינונים והדגשות", - "search_results_for": "תוצאות חיפוש עבור" + "search_results_for": "תוצאות חיפוש עבור", + "help": "עזרה", + "filter_and_highlights_help": "עזרה במסננים והדגשות", + "filter_and_highlights": "מסננים והדגשות" } \ No newline at end of file diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index 423665e1..e9ca1f34 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "यह उत्तर बोर्ड मॉडरेटर द्वारा हटा दिया गया था", "not_mod_of_any_board": "आप किसी बोर्ड के मॉड नहीं हैं।", "filters_and_highlights": "फ़िल्टर और हाइलाइट्स", - "search_results_for": "के लिए खोज परिणाम" + "search_results_for": "के लिए खोज परिणाम", + "help": "मदद", + "filter_and_highlights_help": "फ़िल्टर और हाइलाइट्स सहायता", + "filter_and_highlights": "फ़िल्टर और हाइलाइट्स" } \ No newline at end of file diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index 76500163..33fe9f0f 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Ez a válasz törlésre került a fórum moderátorai által", "not_mod_of_any_board": "Nem vagy moderátor egyetlen táblán sem.", "filters_and_highlights": "Szűrők és kiemelések", - "search_results_for": "Keresési eredmények" + "search_results_for": "Keresési eredmények", + "help": "Segítség", + "filter_and_highlights_help": "Szűrők és kiemelések súgó", + "filter_and_highlights": "Szűrők és kiemelések" } \ No newline at end of file diff --git a/public/translations/id/default.json b/public/translations/id/default.json index 8e6ba818..a07da699 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Balasan ini dihapus oleh moderator papan", "not_mod_of_any_board": "Anda bukan mod di papan manapun.", "filters_and_highlights": "Filter dan Sorotan", - "search_results_for": "Hasil pencarian untuk" + "search_results_for": "Hasil pencarian untuk", + "help": "Bantuan", + "filter_and_highlights_help": "Bantuan Filter dan Sorotan", + "filter_and_highlights": "Filter dan Sorotan" } \ No newline at end of file diff --git a/public/translations/it/default.json b/public/translations/it/default.json index 483ed455..d38e0082 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Questa risposta è stata rimossa dai moderatori del forum", "not_mod_of_any_board": "Non sei un mod di nessuna board.", "filters_and_highlights": "Filtri e Evidenziazioni", - "search_results_for": "Risultati di ricerca per" + "search_results_for": "Risultati di ricerca per", + "help": "Aiuto", + "filter_and_highlights_help": "Aiuto su Filtri e Evidenziazioni", + "filter_and_highlights": "Filtri e Evidenziazioni" } \ No newline at end of file diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index ecef105c..86c89a10 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "この返信は掲示板のモデレーターによって削除されました", "not_mod_of_any_board": "あなたはどのボードのモデレーターでもありません。", "filters_and_highlights": "フィルターとハイライト", - "search_results_for": "検索結果" + "search_results_for": "検索結果", + "help": "ヘルプ", + "filter_and_highlights_help": "フィルターとハイライトのヘルプ", + "filter_and_highlights": "フィルターとハイライト" } \ No newline at end of file diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index 12a791f3..d0c5093c 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "이 답글은 게시판 관리자에 의해 삭제되었습니다", "not_mod_of_any_board": "당신은 어떤 게시판의 관리자도 아닙니다.", "filters_and_highlights": "필터 및 하이라이트", - "search_results_for": "검색 결과" + "search_results_for": "검색 결과", + "help": "도움말", + "filter_and_highlights_help": "필터 및 하이라이트 도움말", + "filter_and_highlights": "필터 및 하이라이트" } \ No newline at end of file diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index d8c983cb..43a896bd 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "या उत्तराला बोर्ड मॉडरेटरने काढले आहे", "not_mod_of_any_board": "तुम्ही कोणत्याही बोर्डचे मॉड नाहीत.", "filters_and_highlights": "फिल्टर आणि हायलाइट्स", - "search_results_for": "सर्च परिणाम" + "search_results_for": "सर्च परिणाम", + "help": "मदत", + "filter_and_highlights_help": "फिल्टर आणि हायलाइट्स सहाय्य", + "filter_and_highlights": "फिल्टर आणि हायलाइट्स" } \ No newline at end of file diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index 933ff4e5..fc9a2b5d 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Dit antwoord is verwijderd door de forum moderators", "not_mod_of_any_board": "Je bent geen mod van een bord.", "filters_and_highlights": "Filters en markeringen", - "search_results_for": "Zoekresultaten voor" + "search_results_for": "Zoekresultaten voor", + "help": "Help", + "filter_and_highlights_help": "Filters en Markeringen Help", + "filter_and_highlights": "Filters en markeringen" } \ No newline at end of file diff --git a/public/translations/no/default.json b/public/translations/no/default.json index d2ff1f21..6d78cf80 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Dette svaret ble fjernet av forummoderatorer", "not_mod_of_any_board": "Du er ikke moderator på noen brett.", "filters_and_highlights": "Filtre og fremhevinger", - "search_results_for": "Søkeresultater for" + "search_results_for": "Søkeresultater for", + "help": "Hjelp", + "filter_and_highlights_help": "Filtre og fremhevinger Hjelp", + "filter_and_highlights": "Filtre og uthevinger" } \ No newline at end of file diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index e1299990..688d198c 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Ta odpowiedź została usunięta przez moderatorów forum", "not_mod_of_any_board": "Nie jesteś moderatorem żadnej tablicy.", "filters_and_highlights": "Filtry i wyróżnienia", - "search_results_for": "Wyniki wyszukiwania dla" + "search_results_for": "Wyniki wyszukiwania dla", + "help": "Pomoc", + "filter_and_highlights_help": "Pomoc dotycząca filtrów i wyróżnień", + "filter_and_highlights": "Filtry i wyróżnienia" } \ No newline at end of file diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index 36fed0ef..fb52e2f8 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Esta resposta foi removida pelos moderadores do fórum", "not_mod_of_any_board": "Você não é um moderador de nenhum quadro.", "filters_and_highlights": "Filtros e Destaques", - "search_results_for": "Resultados de pesquisa para" + "search_results_for": "Resultados de pesquisa para", + "help": "Ajuda", + "filter_and_highlights_help": "Ajuda de Filtros e Realces", + "filter_and_highlights": "Filtros e Destaques" } \ No newline at end of file diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index 70bd1821..57181d73 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Acest răspuns a fost eliminat de moderatorii forumului", "not_mod_of_any_board": "Nu ești moderator pe niciun tablou.", "filters_and_highlights": "Filtre și evidențiere", - "search_results_for": "Rezultatele căutării pentru" + "search_results_for": "Rezultatele căutării pentru", + "help": "Ajutor", + "filter_and_highlights_help": "Ajutor pentru filtre și evidențieri", + "filter_and_highlights": "Filtre și evidențieri" } \ No newline at end of file diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index 950de4d8..443d2ae5 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Этот ответ был удален модераторами форума", "not_mod_of_any_board": "Вы не модератор ни одной доски.", "filters_and_highlights": "Фильтры и выделения", - "search_results_for": "Результаты поиска для" + "search_results_for": "Результаты поиска для", + "help": "Помощь", + "filter_and_highlights_help": "Помощь с фильтрами и выделениями", + "filter_and_highlights": "Фильтры и выделения" } \ No newline at end of file diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index 6752e2b9..a3cc9707 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Ky përgjigje u hoq nga moderatorët e forumit", "not_mod_of_any_board": "Nuk jeni mod në asnjë bord.", "filters_and_highlights": "Filtra dhe theksime", - "search_results_for": "Rezultatet e kërkimit për" + "search_results_for": "Rezultatet e kërkimit për", + "help": "Ndihmë", + "filter_and_highlights_help": "Ndihma për Filtro & Thekso", + "filter_and_highlights": "Filtra dhe thekson" } \ No newline at end of file diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index 0c3bccb3..63883358 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Detta svar togs bort av forumets moderatorer", "not_mod_of_any_board": "Du är inte moderator på någon tavla.", "filters_and_highlights": "Filter och markeringar", - "search_results_for": "Sökresultat för" + "search_results_for": "Sökresultat för", + "help": "Hjälp", + "filter_and_highlights_help": "Filter och markeringar Hjälp", + "filter_and_highlights": "Filter och markeringar" } \ No newline at end of file diff --git a/public/translations/te/default.json b/public/translations/te/default.json index b0aec7b8..ffb84a90 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "ఈ స్పందన బోర్డ్ మోడరేటర్లచే తొలగించబడింది", "not_mod_of_any_board": "మీరు ఎటువంటి బోర్డుకు మోడ్ కంటే లేరు.", "filters_and_highlights": "ఫిల్టర్లు మరియు హైలైట్‌లు", - "search_results_for": "శోధన ఫలితాలు" + "search_results_for": "శోధన ఫలితాలు", + "help": "సహాయం", + "filter_and_highlights_help": "ఫిల్టర్లు మరియు హైలైట్ సహాయం", + "filter_and_highlights": "ఫిల్టర్లు మరియు హైలైట్స్" } \ No newline at end of file diff --git a/public/translations/th/default.json b/public/translations/th/default.json index 92adbe11..885880a5 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "คำตอบนี้ถูกลบโดยผู้ดูแลบอร์ด", "not_mod_of_any_board": "คุณไม่ใช่ผู้ดูแลบอร์ดใดๆ", "filters_and_highlights": "ตัวกรองและไฮไลท์", - "search_results_for": "ผลการค้นหาสำหรับ" + "search_results_for": "ผลการค้นหาสำหรับ", + "help": "ช่วยเหลือ", + "filter_and_highlights_help": "ตัวกรองและการเน้นความช่วยเหลือ", + "filter_and_highlights": "ตัวกรองและไฮไลท์" } \ No newline at end of file diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index 1f74ba69..b74ec005 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Bu yanıt, forum moderatörleri tarafından kaldırıldı", "not_mod_of_any_board": "Hiçbir tahtanın moderatörü değilsiniz.", "filters_and_highlights": "Filtreler ve Vurgular", - "search_results_for": "Arama sonuçları için" + "search_results_for": "Arama sonuçları için", + "help": "Yardım", + "filter_and_highlights_help": "Filtreler ve Vurgular Yardımı", + "filter_and_highlights": "Filtreler ve Vurgular" } \ No newline at end of file diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index 2414bdee..81c3feff 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Цю відповідь було видалено модераторами форуму", "not_mod_of_any_board": "Ви не модератор жодної дошки.", "filters_and_highlights": "Фільтри та виділення", - "search_results_for": "Результати пошуку для" + "search_results_for": "Результати пошуку для", + "help": "Допомога", + "filter_and_highlights_help": "Допомога з фільтрами та підсвічуваннями", + "filter_and_highlights": "Фільтри та виділення" } \ No newline at end of file diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index 93e8c58d..03240bd8 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "اس جواب کو بورڈ کے منتظمین نے حذف کر دیا ہے", "not_mod_of_any_board": "آپ کسی بورڈ کے موڈ نہیں ہیں۔", "filters_and_highlights": "فلٹرز اور ہائیلائٹس", - "search_results_for": "تلاش کے نتائج" + "search_results_for": "تلاش کے نتائج", + "help": "مدد", + "filter_and_highlights_help": "فلٹرز اور ہائی لائٹس کی مدد", + "filter_and_highlights": "فلٹرز اور ہائلाइटس" } \ No newline at end of file diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index 010e1085..9c5cb55d 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "Phản hồi này đã bị xóa bởi các quản trị viên diễn đàn", "not_mod_of_any_board": "Bạn không phải là quản trị viên của bất kỳ bảng nào.", "filters_and_highlights": "Bộ lọc và Nổi bật", - "search_results_for": "Kết quả tìm kiếm cho" + "search_results_for": "Kết quả tìm kiếm cho", + "help": "Trợ giúp", + "filter_and_highlights_help": "Trợ giúp bộ lọc và đánh dấu", + "filter_and_highlights": "Bộ lọc và Nổi bật" } \ No newline at end of file diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index 22bd4a80..b8fc65bc 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -293,5 +293,8 @@ "this_reply_was_removed": "该回复已被版主删除", "not_mod_of_any_board": "您不是任何板块的管理员。", "filters_and_highlights": "筛选器和高亮", - "search_results_for": "搜索结果" + "search_results_for": "搜索结果", + "help": "帮助", + "filter_and_highlights_help": "过滤器和高亮帮助", + "filter_and_highlights": "筛选器与高亮" } \ No newline at end of file diff --git a/src/components/catalog-filters/catalog-filters.module.css b/src/components/catalog-filters/catalog-filters.module.css index 52aecd44..48302ecf 100644 --- a/src/components/catalog-filters/catalog-filters.module.css +++ b/src/components/catalog-filters/catalog-filters.module.css @@ -33,7 +33,7 @@ .modal { top: 60px; - left: 50%; + left: calc(50% + 5px); transform: translateX(-50%); width: 380px; height: auto; @@ -41,14 +41,23 @@ overflow-y: auto; position: fixed; z-index: 5; - padding: 2px; + padding: 2px 5px 5px 5px; font-size: var(--settings-modal-font-size); box-shadow: 0 0 5px rgba(0, 0, 0, 0.25); background-color: var(--settings-modal-background-color); - border-top: var(--settings-modal-border-top); - border-right: var(--settings-modal-border-right); - border-bottom: var(--settings-modal-border-bottom); - border-left: var(--settings-modal-border-left); +} + +.filtersProtipModal { + top: 50px; + width: 570px; + left: 50%; + padding: 2px 10px 5px 10px; +} + +@media (max-width: 614px) { + .filtersProtipModal { + width: 90vw; + } } @media (max-width: 420px) { @@ -82,6 +91,54 @@ top: 5px; image-rendering: pixelated; background-image: var(--settings-modal-close-button-background-image); + cursor: pointer; +} + +.openHelpButton { + right: 23px; + width: 18px; + height: 18px; + display: block; + background-size: 100%; + position: absolute; + top: 5px; + image-rendering: pixelated; + background-image: var(--open-help-button-background-image); + cursor: pointer; +} + +.filtersProtip { + text-transform: none; +} + +.filtersProtip code { + padding: 1px 5px 1px 5px; + background-color: #EEE; + color: #000; +} + +.filtersProtip li { + list-style: none; + margin: 5px 0px; +} + +.filtersProtip ul { + margin-bottom: 10px; +} + +.filtersProtip h4 { + font-size: 15px; + margin: 10px 0px 5px 0px; + padding-top: 10px; +} + +.filtersProtip h4:first-child { + padding-top: 0px; +} + +.filtersProtip h4::before { + content: "»"; + margin-right: 3px; } .filters { @@ -108,16 +165,12 @@ padding-bottom: 5px; } -.saveButton { - margin-left: 5px; -} - .filtersTable { width: 100%; border-spacing: 1px; margin-left: auto; margin-right: auto; - padding: 10px 2px 2px 2px; + padding: 0px 2px 2px 2px; } .filtersTable th { @@ -168,7 +221,8 @@ } .saveButton { - float: right; + position: absolute; + right: 5px; } .filterHits { diff --git a/src/components/catalog-filters/catalog-filters.tsx b/src/components/catalog-filters/catalog-filters.tsx index 257efded..5f662262 100644 --- a/src/components/catalog-filters/catalog-filters.tsx +++ b/src/components/catalog-filters/catalog-filters.tsx @@ -1,11 +1,13 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; +import useFeedResetStore from '../../stores/use-feed-reset-store'; import styles from './catalog-filters.module.css'; const FiltersTable = ({ onSave }: { onSave: () => void }) => { const { t } = useTranslation(); const { filterItems, saveAndApplyFilters, currentSubplebbitAddress } = useCatalogFiltersStore(); + const resetFeed = useFeedResetStore((state) => state.reset); const [localFilterItems, setLocalFilterItems] = useState( filterItems.map((item) => ({ @@ -52,9 +54,17 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { const handleSave = useCallback(() => { const nonEmptyFilters = localFilterItems.filter((item) => item.text.trim() !== ''); + saveAndApplyFilters(nonEmptyFilters); + + useCatalogFiltersStore.getState().resetCountsForCurrentSubplebbit(); + + if (resetFeed) { + resetFeed(); + } + onSave(); - }, [saveAndApplyFilters, localFilterItems, onSave]); + }, [saveAndApplyFilters, localFilterItems, onSave, resetFeed]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -142,7 +152,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
))} @@ -163,18 +173,107 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { ); }; +const FiltersProtip = () => { + return ( +
+

Patterns

+
    +
  • + Matching whole words: +
  • +
  • + feel — will match "feel" but not "feeling". This search is case-insensitive. +
  • +
+
    +
  • + AND operator: +
  • +
  • + feel girlfriend — will match "feel" AND "girlfriend" in any order. +
  • +
+
    +
  • + OR operator: +
  • +
  • + feel|girlfriend — will match "feel" OR "girlfriend". +
  • +
+
    +
  • + Mixing both operators: +
  • +
  • + girlfriend|boyfriend feel — matches "feel" AND "girlfriend", or "feel" AND "boyfriend". +
  • +
+
    +
  • + Exact match search: +
  • +
  • + "that feel when" — place double quotes around the pattern to search for an exact string. +
  • +
+
    +
  • + Wildcards: +
  • +
  • + feel* — matches expressions such as "feel", "feels", "feeling", "feeler", etc… +
  • +
  • + idolm*ster — this can match "idolmaster" or "idolm@ster", etc… +
  • +
+
    + It is also possible to filter by regular expression: +
  • + /^(?=.*detachable)(?=.*hats).*$/i — AND operator. +
  • +
  • + /^(?!.*touhou).*$/i — NOT operator. +
  • +
  • + {'/^>/'} — threads starting with a quote ({'">"'} character as an html entity). +
  • +
  • + /^$/ — threads with no text. +
  • +
+

Controls

+
    +
  • + On — enables or disables the filter. +
  • +
  • + Hide — hides matched threads. +
  • +
  • + Top — moves the filter to the top of the feed. +
  • +
+
+ ); +}; + const FiltersModal = ({ closeModal }: { closeModal: () => void }) => { const { t } = useTranslation(); - + const [showHelp, setShowHelp] = useState(false); + const openHelp = () => setShowHelp(true); + const closeHelp = () => setShowHelp(false); return ( <> -
-
+
+
- {t('filters_and_highlights')} - + {showHelp ? t('filter_and_highlights_help') : t('filter_and_highlights')} + {!showHelp && } +
- + {showHelp ? : }
); diff --git a/src/lib/utils/pattern-utils.ts b/src/lib/utils/pattern-utils.ts new file mode 100644 index 00000000..81484791 --- /dev/null +++ b/src/lib/utils/pattern-utils.ts @@ -0,0 +1,110 @@ +import { Comment } from '@plebbit/plebbit-react-hooks'; + +/** + * Checks if a text matches a pattern according to various pattern matching rules: + * - Whole word matching: 'feel' matches 'feel' but not 'feeling' + * - AND operator: 'feel girlfriend' matches both words in any order + * - OR operator: 'feel|girlfriend' matches either word + * - Mixed operators: 'girlfriend|boyfriend feel' matches complex logic + * - Exact match: "that feel when" matches the exact phrase + * - Wildcards: 'feel*' matches 'feel', 'feels', 'feeling', etc. + * - Regex: /pattern/flags for regular expressions + * + * @param text The text to check against the pattern + * @param pattern The pattern to match + * @returns True if the text matches the pattern, false otherwise + */ +export const matchesPattern = (text: string, pattern: string): boolean => { + if (!pattern || !text) return false; + + const textLower = text.toLowerCase(); + + try { + // Check if it's a regex pattern (starts and ends with /) + if (pattern.startsWith('/') && pattern.length > 2 && /\/[gimsuy]*$/.test(pattern)) { + const lastSlashIndex = pattern.lastIndexOf('/'); + const regexPattern = pattern.substring(1, lastSlashIndex); + const flags = pattern.substring(lastSlashIndex + 1); + const regex = new RegExp(regexPattern, flags); + return regex.test(textLower); + } + // Check if it's an exact match pattern (surrounded by quotes) + else if (pattern.startsWith('"') && pattern.endsWith('"') && pattern.length > 2) { + const exactPattern = pattern.substring(1, pattern.length - 1).toLowerCase(); + return textLower.includes(exactPattern); + } + // Check for OR patterns (contains |) + else if (pattern.includes('|')) { + const orGroups = pattern.split(' ').filter(Boolean); + + // Process each group (which might contain OR operators) + return orGroups.every((group) => { + const orTerms = group.split('|').map((term) => term.toLowerCase()); + return orTerms.some((term) => { + // Handle wildcards in OR terms + if (term.includes('*')) { + const regexPattern = term.replace(/\*/g, '.*').toLowerCase(); + const regex = new RegExp(`\\b${regexPattern}\\b`, 'i'); + return regex.test(textLower); + } else { + // Match whole word only + const regex = new RegExp(`\\b${term}\\b`, 'i'); + return regex.test(textLower); + } + }); + }); + } + // Check for AND patterns (space-separated words) + else if (pattern.includes(' ')) { + const andTerms = pattern + .split(' ') + .filter(Boolean) + .map((term) => term.toLowerCase()); + return andTerms.every((term) => { + // Handle wildcards in AND terms + if (term.includes('*')) { + const regexPattern = term.replace(/\*/g, '.*').toLowerCase(); + const regex = new RegExp(`\\b${regexPattern}\\b`, 'i'); + return regex.test(textLower); + } else { + // Match whole word only + const regex = new RegExp(`\\b${term}\\b`, 'i'); + return regex.test(textLower); + } + }); + } + // Handle wildcard patterns + else if (pattern.includes('*')) { + const regexPattern = pattern.replace(/\*/g, '.*').toLowerCase(); + const regex = new RegExp(`\\b${regexPattern}\\b`, 'i'); + return regex.test(textLower); + } + // Simple whole word match + else { + const regex = new RegExp(`\\b${pattern.toLowerCase()}\\b`, 'i'); + return regex.test(textLower); + } + } catch (error) { + // If regex parsing fails, fall back to simple includes + console.error('Pattern matching error:', error); + const simplifiedPattern = pattern.toLowerCase(); + return textLower.includes(simplifiedPattern); + } +}; + +/** + * Checks if a comment matches a pattern in its title or content + * + * @param comment The comment to check + * @param pattern The pattern to match + * @returns True if the comment matches the pattern, false otherwise + */ +export const commentMatchesPattern = (comment: Comment, pattern: string): boolean => { + if (!pattern || !comment) return false; + + const titleLower = comment?.title?.toLowerCase() || ''; + const contentLower = comment?.content?.toLowerCase() || ''; + const textToMatch = titleLower + ' ' + contentLower; + + return matchesPattern(textToMatch, pattern); +}; diff --git a/src/stores/use-catalog-filters-store.ts b/src/stores/use-catalog-filters-store.ts index f4f106bc..c75072d7 100644 --- a/src/stores/use-catalog-filters-store.ts +++ b/src/stores/use-catalog-filters-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { Comment } from '@plebbit/plebbit-react-hooks'; +import { commentMatchesPattern } from '../lib/utils/pattern-utils'; interface FilterItem { text: string; @@ -34,6 +35,7 @@ interface CatalogFiltersStore { searchText: string; setSearchFilter: (text: string) => void; clearSearchFilter: () => void; + resetCountsForCurrentSubplebbit: () => void; } const useCatalogFiltersStore = create( @@ -137,10 +139,39 @@ const useCatalogFiltersStore = create( hide: item.hide ?? true, top: item.top ?? false, })); + + // Compare new filter items with existing ones to detect pattern changes + const existingItems = get().filterItems; + const updatedItems = nonEmptyItems.map((newItem) => { + // Try to find matching existing item by index or text + const existingItem = existingItems.find((item) => item.text === newItem.text); + + // If we found a matching item, keep its counts + if (existingItem) { + return { + ...newItem, + count: existingItem.count, + filteredCids: existingItem.filteredCids, + subplebbitCounts: existingItem.subplebbitCounts, + subplebbitFilteredCids: existingItem.subplebbitFilteredCids, + }; + } + + // If pattern has changed or it's a new filter, reset all counts + return { + ...newItem, + count: 0, + filteredCids: new Set(), + subplebbitCounts: new Map(), + subplebbitFilteredCids: new Map>(), + }; + }); + set({ - filterItems: nonEmptyItems, + filterItems: updatedItems, filteredCids: new Set(), }); + get().recalcFilteredCount(); get().updateFilter(); }, @@ -154,11 +185,7 @@ const useCatalogFiltersStore = create( // Apply search filter if (state.searchText.trim() !== '') { - const searchPattern = state.searchText.toLowerCase(); - const title = comment?.title?.toLowerCase() || ''; - const content = comment?.content?.toLowerCase() || ''; - - if (!title.includes(searchPattern) && !content.includes(searchPattern)) { + if (!commentMatchesPattern(comment, state.searchText)) { return false; } } @@ -170,11 +197,7 @@ const useCatalogFiltersStore = create( for (let i = 0; i < filterItems.length; i++) { const item = filterItems[i]; if (item.enabled && item.text.trim() !== '') { - const pattern = item.text.toLowerCase(); - const title = comment?.title?.toLowerCase() || ''; - const content = comment?.content?.toLowerCase() || ''; - - if (title.includes(pattern) || content.includes(pattern)) { + if (commentMatchesPattern(comment, item.text)) { // If we have a current subplebbit and this is a match, increment the count if (currentSubplebbit && comment.subplebbitAddress === currentSubplebbit) { // We need to use a timeout to avoid modifying state during a state update @@ -272,6 +295,38 @@ const useCatalogFiltersStore = create( return filteredCount; }, + resetCountsForCurrentSubplebbit: () => { + const currentSubplebbit = get().currentSubplebbitAddress; + if (!currentSubplebbit) return; + + set((state) => { + const updatedFilterItems = state.filterItems.map((item) => { + const newItem = { ...item }; + + // Ensure maps are properly initialized + if (!newItem.subplebbitCounts || !(newItem.subplebbitCounts instanceof Map)) { + newItem.subplebbitCounts = new Map(); + } + if (!newItem.subplebbitFilteredCids || !(newItem.subplebbitFilteredCids instanceof Map)) { + newItem.subplebbitFilteredCids = new Map(); + } + + // Reset counts for current subplebbit + newItem.subplebbitCounts.set(currentSubplebbit, 0); + newItem.subplebbitFilteredCids.set(currentSubplebbit, new Set()); + + return newItem; + }); + + return { + filterItems: updatedFilterItems, + filteredCount: 0, + }; + }); + + // Trigger filter reapplication to start counting again + get().updateFilter(); + }, }), { name: 'catalog-filters-storage', diff --git a/src/themes.css b/src/themes.css index f25059a3..93e6b01c 100644 --- a/src/themes.css +++ b/src/themes.css @@ -50,6 +50,7 @@ --button-desktop-text-color: #00e; --button-desktop-text-color-hover: red; --close-button-background-image: url("/assets/buttons/cross-red.png"); + --open-help-button-background-image: url("/assets/buttons/help-red.png"); /* horizontal rule */ --hr-border: none; @@ -263,6 +264,7 @@ --button-desktop-text-color: #34345c; --button-desktop-text-color-hover: #d00; --close-button-background-image: url("/assets/buttons/cross-blue.png"); + --open-help-button-background-image: url("/assets/buttons/help-blue.png"); /* footer */ --footer-item-background-color-desktop: #eef2ff; @@ -479,6 +481,7 @@ --button-desktop-text-color-hover: red; --button-text-decoration: underline; --close-button-background-image: url("/assets/buttons/cross-red.png"); + --open-help-button-background-image: url("/assets/buttons/help-red.png"); /* footer */ --footer-item-background-color-desktop: #fed; @@ -657,7 +660,7 @@ --button-text-decoration: underline; --button-desktop-text-color-hover: #d00; --close-button-background-image: url("/assets/buttons/cross-blue.png"); - + --open-help-button-background-image: url("/assets/buttons/help-blue.png"); /* footer */ --footer-item-background-color-desktop: #eef2ff; @@ -848,7 +851,7 @@ --button-desktop-text-color: #81a2be; --button-desktop-text-color-hover: #5f89ac; --close-button-background-image: url("/assets/buttons/cross-dark.png"); - + --open-help-button-background-image: url("/assets/buttons/help-dark.png"); /* filter for inputs */ --filter80: brightness(85%); @@ -1067,7 +1070,7 @@ --button-desktop-text-color: #f60; --button-desktop-text-color-hover: #f30; --close-button-background-image: url("/assets/buttons/cross-photon.png"); - + --open-help-button-background-image: url("/assets/buttons/help-photon.png"); /* horizontal rule */ --hr-border: none; --hr-border-top: 1px solid #ddd; diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx index 28a5d2f3..97ab0714 100644 --- a/src/views/catalog/catalog.tsx +++ b/src/views/catalog/catalog.tsx @@ -18,6 +18,7 @@ import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; import CatalogRow from '../../components/catalog-row'; import LoadingEllipsis from '../../components/loading-ellipsis'; import styles from './catalog.module.css'; +import { commentMatchesPattern } from '../../lib/utils/pattern-utils'; const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; @@ -39,15 +40,12 @@ const createContentFilter = ( if (enabledFilters.length === 0) return true; - const titleLower = comment?.title?.toLowerCase() || ''; - const contentLower = comment?.content?.toLowerCase() || ''; - // Check if any enabled filter matches the content for (let i = 0; i < enabledFilters.length; i++) { const item = enabledFilters[i]; - const pattern = item.text.toLowerCase(); + const pattern = item.text; - if (titleLower.includes(pattern) || contentLower.includes(pattern)) { + if (commentMatchesPattern(comment, pattern)) { // Find the original filter index to increment count const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled); if (filterIndex !== -1) { @@ -102,12 +100,7 @@ const createCombinedFilter = ( const searchFilter = { filter: (comment: Comment) => { if (!searchText.trim()) return true; - - const titleLower = comment?.title?.toLowerCase() || ''; - const contentLower = comment?.content?.toLowerCase() || ''; - const searchPattern = searchText.toLowerCase(); - - return titleLower.includes(searchPattern) || contentLower.includes(searchPattern); + return commentMatchesPattern(comment, searchText); }, key: searchText ? `search-filter-${searchText}` : 'no-search-filter', }; @@ -410,13 +403,9 @@ const Catalog = () => { combinedFeed.forEach((comment) => { if (!comment) return; - const titleLower = comment?.title?.toLowerCase() || ''; - const contentLower = comment?.content?.toLowerCase() || ''; - let isTop = false; for (const filter of enabledTopFilters) { - const pattern = filter.text.toLowerCase(); - if (titleLower.includes(pattern) || contentLower.includes(pattern)) { + if (commentMatchesPattern(comment, filter.text)) { isTop = true; break; } From 99fdb90578a146baf875466a517e92ce27da9531 Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Thu, 6 Mar 2025 23:54:05 +0100 Subject: [PATCH 12/18] fix mobile responsiveness --- .../catalog-filters.module.css | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/catalog-filters/catalog-filters.module.css b/src/components/catalog-filters/catalog-filters.module.css index 48302ecf..cf506932 100644 --- a/src/components/catalog-filters/catalog-filters.module.css +++ b/src/components/catalog-filters/catalog-filters.module.css @@ -41,12 +41,18 @@ overflow-y: auto; position: fixed; z-index: 5; - padding: 2px 5px 5px 5px; + padding: 2px 5px 3px 5px; font-size: var(--settings-modal-font-size); box-shadow: 0 0 5px rgba(0, 0, 0, 0.25); background-color: var(--settings-modal-background-color); } +@media (max-width: 640px) { + .modal { + padding-bottom: 0; + } +} + .filtersProtipModal { top: 50px; width: 570px; @@ -109,6 +115,7 @@ .filtersProtip { text-transform: none; + text-align: left; } .filtersProtip code { @@ -204,6 +211,12 @@ width: 150px; } +@media (max-width: 640px) { + .filtersTable input[type='text'] { + font-size: 16px !important; + } +} + .filtersTable input[type='text']:focus { border: var(--reply-modal-field-input-border-focus, revert); } @@ -224,6 +237,11 @@ position: absolute; right: 5px; } +@media (max-width: 640px) { + .addButton, .saveButton { + margin-bottom: 4px !important; + } +} .filterHits { font-size: 12px !important; From d8e4a4656e084c02e2c51bc1c02af8141f3d4d88 Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Fri, 7 Mar 2025 11:29:04 +0100 Subject: [PATCH 13/18] Update post.tsx --- src/views/post/post.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index 8a3fe8a0..e2cb3990 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Comment, Role, useComment, useEditedComment } from '@plebbit/plebbit-react-hooks'; +import { Comment, Role, useComment, useEditedComment, useSubplebbit } from '@plebbit/plebbit-react-hooks'; import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits'; import { useLocation, useParams } from 'react-router-dom'; import { isAllView, isDescriptionView, isRulesView } from '../../lib/utils/view-utils'; @@ -57,10 +57,9 @@ const PostPage = () => { const isInDescriptionView = isDescriptionView(location.pathname, params); const isInRulesView = isRulesView(location.pathname, params); - const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress as string]); - const { createdAt, description, rules, shortAddress, suggested, title } = subplebbit; - const comment = useComment({ commentCid }); + const subplebbit = useSubplebbit({ subplebbitAddress }); + const { createdAt, description, rules, shortAddress, suggested, title } = subplebbit || {}; // if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread const postComment = useComment({ commentCid: comment?.postCid }); From fc674f4caf755bb3667a70f6735ae39003e17932 Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Fri, 7 Mar 2025 14:31:58 +0100 Subject: [PATCH 14/18] fix(css): floating-ui portal could override app color scheme --- src/index.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/index.css b/src/index.css index 3bf6c15f..97e46b19 100644 --- a/src/index.css +++ b/src/index.css @@ -52,6 +52,10 @@ hr { color-scheme: dark; } +[data-floating-ui-portal] { + color-scheme: var(--color-scheme) !important; +} + .yellowOfflineIcon { background-image: url('/assets/icons/offline-blue.png'); } From 6edbed8d16f3fe34fe351b3a0f1405fb36cf9dbc Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Fri, 7 Mar 2025 16:53:19 +0100 Subject: [PATCH 15/18] add banner --- public/assets/banners/banner-63.jpg | Bin 0 -> 62649 bytes src/components/board-header/board-header.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 public/assets/banners/banner-63.jpg diff --git a/public/assets/banners/banner-63.jpg b/public/assets/banners/banner-63.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c5abbabe304f2a9d90d75f18f3ea7995dc8d95b1 GIT binary patch literal 62649 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 { const [imagePath] = useState(() => { From 22b828b3e834fdd6ce5658af1457792403793394 Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Fri, 7 Mar 2025 17:43:09 +0100 Subject: [PATCH 16/18] feat(catalog filters): add color highlighting of threads matching pattern --- .../catalog-filters.module.css | 49 ------ .../catalog-filters/catalog-filters.tsx | 118 +++----------- .../filters-protip/filters-protip.module.css | 34 ++++ .../filters-protip/filters-protip.tsx | 89 +++++++++++ .../catalog-filters/filters-protip/index.ts | 1 + .../highlight-color-picker.module.css | 110 +++++++++++++ .../highlight-color-picker.tsx | 145 ++++++++++++++++++ .../highlight-color-picker/index.ts | 1 + .../catalog-row/catalog-row.module.css | 6 +- src/components/catalog-row/catalog-row.tsx | 34 ++-- src/stores/use-catalog-filters-store.ts | 26 ++++ src/views/catalog/catalog.tsx | 42 ++++- 12 files changed, 495 insertions(+), 160 deletions(-) create mode 100644 src/components/catalog-filters/filters-protip/filters-protip.module.css create mode 100644 src/components/catalog-filters/filters-protip/filters-protip.tsx create mode 100644 src/components/catalog-filters/filters-protip/index.ts create mode 100644 src/components/catalog-filters/highlight-color-picker/highlight-color-picker.module.css create mode 100644 src/components/catalog-filters/highlight-color-picker/highlight-color-picker.tsx create mode 100644 src/components/catalog-filters/highlight-color-picker/index.ts diff --git a/src/components/catalog-filters/catalog-filters.module.css b/src/components/catalog-filters/catalog-filters.module.css index cf506932..c8609028 100644 --- a/src/components/catalog-filters/catalog-filters.module.css +++ b/src/components/catalog-filters/catalog-filters.module.css @@ -113,41 +113,6 @@ cursor: pointer; } -.filtersProtip { - text-transform: none; - text-align: left; -} - -.filtersProtip code { - padding: 1px 5px 1px 5px; - background-color: #EEE; - color: #000; -} - -.filtersProtip li { - list-style: none; - margin: 5px 0px; -} - -.filtersProtip ul { - margin-bottom: 10px; -} - -.filtersProtip h4 { - font-size: 15px; - margin: 10px 0px 5px 0px; - padding-top: 10px; -} - -.filtersProtip h4:first-child { - padding-top: 0px; -} - -.filtersProtip h4::before { - content: "»"; - margin-right: 3px; -} - .filters { padding: 5px; text-align: left; @@ -247,17 +212,3 @@ font-size: 12px !important; text-transform: none; } - -.clickbox { - width: 16px; - height: 16px; - line-height: 16px; - font-size: 10px; - display: block; - text-align: center; - background-color: #fff; - border: 1px solid #aaa; - text-decoration: none; - color: #000; - margin: auto; -} \ No newline at end of file diff --git a/src/components/catalog-filters/catalog-filters.tsx b/src/components/catalog-filters/catalog-filters.tsx index 5f662262..c2eed963 100644 --- a/src/components/catalog-filters/catalog-filters.tsx +++ b/src/components/catalog-filters/catalog-filters.tsx @@ -2,6 +2,8 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; import useFeedResetStore from '../../stores/use-feed-reset-store'; +import FiltersProtip from './filters-protip'; +import HighlightColorPicker from './highlight-color-picker'; import styles from './catalog-filters.module.css'; const FiltersTable = ({ onSave }: { onSave: () => void }) => { @@ -14,6 +16,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { ...item, hide: item.hide ?? true, top: item.top ?? false, + color: item.color ?? '', })), ); @@ -26,6 +29,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { ...item, hide: item.hide ?? true, top: item.top ?? false, + color: item.color ?? '', })), ); }, [filterItems]); @@ -47,6 +51,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { subplebbitFilteredCids: new Map>(), hide: true, top: false, + color: '', }, ]; }); @@ -138,7 +143,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { />
{t('order')}{t('enable')}{t('text')}{t('delete')}orderonpatterncolorhidetopdel
+ + + updateLocalFilterItem(index, { ...item, hide: e.target.checked })} /> + + updateLocalFilterItem(index, { ...item, top: e.target.checked })} /> + removeLocalFilterItem(index)}> × {item.count > 0 && `x${item.count}`}
+ @@ -112,7 +153,7 @@ const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
- {t('filters')} + {t('filters_and_highlights')}
@@ -125,13 +166,23 @@ const CatalogFilters = () => { const { t } = useTranslation(); const [showModal, setShowModal] = useState(false); - const closeModal = () => { + const closeModal = useCallback(() => { setShowModal(false); - }; + }, []); + + useEffect(() => { + const onEscapeKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + closeModal(); + } + }; + document.addEventListener('keydown', onEscapeKey); + return () => document.removeEventListener('keydown', onEscapeKey); + }, [closeModal]); return ( <> - setShowModal(false)} style={{ cursor: 'not-allowed' }}> + setShowModal(true)}> {t('filters')} {showModal && } diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx index 09f0f2e5..16f8da82 100644 --- a/src/views/catalog/catalog.tsx +++ b/src/views/catalog/catalog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { Link, useLocation, useParams } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import { Comment, useAccount, useFeed, useSubplebbit, useBlock, useAccountComments } from '@plebbit/plebbit-react-hooks'; @@ -14,22 +14,97 @@ import useCatalogStyleStore from '../../stores/use-catalog-style-store'; import useFeedResetStore from '../../stores/use-feed-reset-store'; import useInterfaceSettingsStore from '../../stores/use-interface-settings-store'; import useSortingStore from '../../stores/use-sorting-store'; +import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; import CatalogRow from '../../components/catalog-row'; import LoadingEllipsis from '../../components/loading-ellipsis'; import styles from './catalog.module.css'; const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; -const createThreadsWithoutImagesFilter = () => ({ - filter: (comment: Comment) => { - const { link, linkHeight, linkWidth, thumbnailUrl } = comment || {}; - if (!getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link)) { - return false; - } - return true; - }, - key: 'threads-with-images-only', -}); +const createContentFilter = ( + filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set; hide: boolean; top: boolean }[], + onFilterMatch?: (filterIndex: number, cid: string) => void, +) => { + // Create a unique key based on the enabled filter items + const enabledFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== ''); + const filterKey = + enabledFilters.length > 0 + ? `content-filter-${enabledFilters.map((item) => `${item.text}-${item.hide ? 'hide' : ''}-${item.top ? 'top' : ''}`).join('-')}` + : 'no-content-filter'; + + return { + filter: (comment: Comment) => { + if (!comment?.cid) return true; + + if (enabledFilters.length === 0) return true; + + const titleLower = comment?.title?.toLowerCase() || ''; + const contentLower = comment?.content?.toLowerCase() || ''; + + // Check if any enabled filter matches the content + for (let i = 0; i < enabledFilters.length; i++) { + const item = enabledFilters[i]; + const pattern = item.text.toLowerCase(); + + if (titleLower.includes(pattern) || contentLower.includes(pattern)) { + // Find the original filter index to increment count + const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled); + if (filterIndex !== -1) { + if (onFilterMatch) { + onFilterMatch(filterIndex, comment.cid); + } else { + // Fallback to the store method if no callback provided + useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, comment.cid); + } + } + + // If this filter is set to hide, filter out the comment + if (item.hide) { + return false; + } + + // If this filter is set to top, we'll handle it separately in the component + // (we don't filter it out here) + } + } + + return true; + }, + key: filterKey, + }; +}; + +const createImageFilter = (showTextOnlyThreads: boolean) => { + return { + filter: (comment: Comment) => { + if (showTextOnlyThreads) return true; + + const { link, linkHeight, linkWidth, thumbnailUrl } = comment || {}; + const hasThumbnail = getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link); + + return hasThumbnail; + }, + key: showTextOnlyThreads ? 'no-image-filter' : 'threads-with-images-only', + }; +}; + +const createCombinedFilter = ( + showTextOnlyThreads: boolean, + filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set; hide: boolean; top: boolean }[], + onFilterMatch?: (filterIndex: number, cid: string) => void, +) => { + const imageFilter = createImageFilter(showTextOnlyThreads); + const contentFilter = createContentFilter(filterItems, onFilterMatch); + + return { + filter: (comment: Comment) => { + if (!imageFilter.filter(comment)) return false; + + return contentFilter.filter(comment); + }, + key: `${imageFilter.key}-${contentFilter.key}`, + }; +}; const Catalog = () => { const { t } = useTranslation(); @@ -39,7 +114,7 @@ const Catalog = () => { const isInAllView = isAllView(location.pathname); const defaultSubplebbits = useDefaultSubplebbits(); const { hideAdultBoards, hideGoreBoards } = useInterfaceSettingsStore(); - const { hideThreadsWithoutImages } = useInterfaceSettingsStore(); + const { showTextOnlyThreads, filterItems } = useCatalogFiltersStore(); const account = useAccount(); const subscriptions = account?.subscriptions; @@ -77,12 +152,17 @@ const Catalog = () => { const { timeFilterSeconds, timeFilterName } = useTimeFilter(); const { sortType } = useSortingStore(); + // stable callback for filter matching + const handleFilterMatch = useCallback((filterIndex: number, cid: string) => { + useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, cid); + }, []); + const feedOptions = useMemo(() => { const options: any = { subplebbitAddresses, sortType, postsPerPage: isInAllView || isInSubscriptionsView ? 10 : postsPerPage, - filter: hideThreadsWithoutImages ? createThreadsWithoutImagesFilter() : undefined, + filter: createCombinedFilter(showTextOnlyThreads, filterItems, handleFilterMatch), }; if (isInAllView || isInSubscriptionsView) { @@ -90,7 +170,7 @@ const Catalog = () => { } return options; - }, [subplebbitAddresses, sortType, isInAllView, isInSubscriptionsView, postsPerPage, timeFilterSeconds, hideThreadsWithoutImages]); + }, [subplebbitAddresses, sortType, isInAllView, isInSubscriptionsView, postsPerPage, timeFilterSeconds, showTextOnlyThreads, filterItems, handleFilterMatch]); const { feed, hasMore, loadMore, reset, subplebbitAddressesWithNewerPosts } = useFeed(feedOptions); const { accountComments } = useAccountComments(); @@ -108,13 +188,13 @@ const Catalog = () => { timestamp > Date.now() / 1000 - 60 * 60 && state === 'succeeded' && cid && - (hideThreadsWithoutImages ? getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), comment?.link) : true) && + (showTextOnlyThreads ? getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), comment?.link) : true) && cid === postCid && comment?.subplebbitAddress === subplebbitAddress && !feed.some((post) => post.cid === cid) ); }), - [accountComments, subplebbitAddress, feed, hideThreadsWithoutImages], + [accountComments, subplebbitAddress, feed, showTextOnlyThreads], ); // show newest account comment at the top of the feed but after pinned posts @@ -146,13 +226,14 @@ const Catalog = () => { subplebbitAddresses, sortType, newerThan: 60 * 60 * 24 * 7, - filter: hideThreadsWithoutImages ? createThreadsWithoutImagesFilter() : undefined, + filter: createCombinedFilter(showTextOnlyThreads, filterItems, handleFilterMatch), }); + const { feed: monthlyFeed } = useFeed({ subplebbitAddresses, sortType, newerThan: 60 * 60 * 24 * 30, - filter: hideThreadsWithoutImages ? createThreadsWithoutImagesFilter() : undefined, + filter: createCombinedFilter(showTextOnlyThreads, filterItems, handleFilterMatch), }); const [showMorePostsSuggestion, setShowMorePostsSuggestion] = useState(false); @@ -265,7 +346,44 @@ const Catalog = () => { const isFeedLoaded = feed.length > 0 || state === 'failed'; - const rows = useCatalogFeedRows(columnCount, combinedFeed, isFeedLoaded, subplebbit); + // Process the feed to move "top" posts to the top + const processedFeed = useMemo(() => { + if (!combinedFeed || combinedFeed.length === 0) return combinedFeed; + + const enabledTopFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== '' && item.top); + if (enabledTopFilters.length === 0) return combinedFeed; + + // Separate posts that match "top" filters + const topPosts: Comment[] = []; + const regularPosts: Comment[] = []; + + combinedFeed.forEach((comment) => { + if (!comment) return; + + const titleLower = comment?.title?.toLowerCase() || ''; + const contentLower = comment?.content?.toLowerCase() || ''; + + let isTop = false; + for (const filter of enabledTopFilters) { + const pattern = filter.text.toLowerCase(); + if (titleLower.includes(pattern) || contentLower.includes(pattern)) { + isTop = true; + break; + } + } + + if (isTop) { + topPosts.push(comment); + } else { + regularPosts.push(comment); + } + }); + + // Return top posts followed by regular posts + return [...topPosts, ...regularPosts]; + }, [combinedFeed, filterItems]); + + const rows = useCatalogFeedRows(columnCount, processedFeed, isFeedLoaded, subplebbit); // save the last Virtuoso state to restore it when navigating back const virtuosoRef = useRef(null); @@ -293,7 +411,7 @@ const Catalog = () => {

- {combinedFeed.length !== 0 ? ( + {processedFeed.length !== 0 ? ( <> Date: Thu, 6 Mar 2025 13:15:33 +0100 Subject: [PATCH 05/18] perf: prioritize cached data from API, improving navigation speed and memory consumption --- src/components/board-buttons/board-buttons.tsx | 9 ++++++--- src/components/board-header/board-header.tsx | 6 ++++-- src/components/post-form/post-form.tsx | 13 ++++++++----- src/components/reply-modal/reply-modal.tsx | 8 +++++--- .../subplebbit-stats/subplebbit-stats.tsx | 8 +++++--- src/views/post/post.tsx | 4 ++-- 6 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/components/board-buttons/board-buttons.tsx b/src/components/board-buttons/board-buttons.tsx index d64121ae..073a504d 100644 --- a/src/components/board-buttons/board-buttons.tsx +++ b/src/components/board-buttons/board-buttons.tsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next'; import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; -import { useAccountComment, useComment, useSubplebbit, useSubscribe } from '@plebbit/plebbit-react-hooks'; +import { useAccountComment, useSubscribe } from '@plebbit/plebbit-react-hooks'; +import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits'; +import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages'; import { isAllView, isCatalogView, isDescriptionView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; import useCatalogStyleStore from '../../stores/use-catalog-style-store'; import useFeedResetStore from '../../stores/use-feed-reset-store'; @@ -297,8 +299,9 @@ const PostPageStats = () => { const location = useLocation(); const isInDescriptionView = isDescriptionView(location.pathname, params); - const comment = useComment({ commentCid: params?.commentCid }); - const subplebbit = useSubplebbit({ subplebbitAddress: params?.subplebbitAddress }); + const comment = useSubplebbitsPagesStore((state) => state.comments[params?.commentCid as string]); + const subplebbit = useSubplebbitsStore((state) => state.subplebbits[params?.subplebbitAddress as string]); + const descriptionReplyCount = location?.pathname.startsWith('/p/all/') ? 0 : subplebbit?.rules?.length > 0 ? 1 : 0; const { closed, pinned, replyCount } = comment || {}; const linkCount = useCountLinksInReplies(comment); diff --git a/src/components/board-header/board-header.tsx b/src/components/board-header/board-header.tsx index 071511d4..3d5d5ef5 100644 --- a/src/components/board-header/board-header.tsx +++ b/src/components/board-header/board-header.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; -import { useAccountComment, useSubplebbit } from '@plebbit/plebbit-react-hooks'; +import { useAccountComment } from '@plebbit/plebbit-react-hooks'; +import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits'; import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils'; import styles from './board-header.module.css'; import { useMultisubMetadata } from '../../hooks/use-default-subplebbits'; @@ -29,7 +30,8 @@ const BoardHeader = () => { const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any }); const subplebbitAddress = params?.subplebbitAddress || accountComment?.subplebbitAddress; - const subplebbit = useSubplebbit({ subplebbitAddress }); + const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]); + const { address, shortAddress } = subplebbit || {}; const multisubMetadata = useMultisubMetadata(); diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index e02b00a8..1d107805 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; -import { Comment, setAccount, useAccount, useAccountComment, useComment, useEditedComment, useSubplebbit } from '@plebbit/plebbit-react-hooks'; +import { Comment, setAccount, useAccount, useAccountComment, useEditedComment } from '@plebbit/plebbit-react-hooks'; +import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits'; +import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages'; import { getHasThumbnail, getLinkMediaInfo } from '../../lib/utils/media-utils'; import { formatMarkdown } from '../../lib/utils/post-utils'; import { isValidURL } from '../../lib/utils/url-utils'; @@ -56,7 +58,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const defaultSubplebbitAddresses = useDefaultSubplebbitAddresses(); const { anonMode, getNewSigner, getExistingSigner } = useAnonMode(postCid); - const comment = useComment({ commentCid: postCid }); + const comment = useSubplebbitsPagesStore((state) => state.comments[postCid]); const address = comment?.author?.address; const [lengthError, setLengthError] = useState(null); @@ -427,9 +429,10 @@ const PostForm = () => { const isInPostView = isPostPageView(location.pathname, params); const isInRulesView = isRulesView(location.pathname, params); const isInAllView = isAllView(location.pathname); - const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); + const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); - const post = useComment({ commentCid: useParams().commentCid }); + const commentCid = params?.commentCid; + const post = useSubplebbitsPagesStore((state) => state.comments[commentCid as string]); let comment: Comment = post; // handle pending mod or author edit const { editedComment } = useEditedComment({ comment }); @@ -444,7 +447,7 @@ const PostForm = () => { const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any }); const subplebbitAddress = params?.subplebbitAddress || accountComment?.subplebbitAddress; - const subplebbit = useSubplebbit({ subplebbitAddress }); + const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]); const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsSubplebbitOffline(subplebbit); return ( diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 79a9cb35..5f29ab27 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; -import { setAccount, useAccount, useComment, useSubplebbit } from '@plebbit/plebbit-react-hooks'; +import { setAccount, useAccount } from '@plebbit/plebbit-react-hooks'; +import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits'; +import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages'; import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js'; import { formatMarkdown } from '../../lib/utils/post-utils'; import { getFormattedTimeAgo } from '../../lib/utils/time-utils'; @@ -45,7 +47,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, postCid, scrollY, s const { selectedText } = useSelectedTextStore(); const { anonMode, getNewSigner, getExistingSigner } = useAnonMode(postCid); - const comment = useComment({ commentCid: postCid }); + const comment = useSubplebbitsPagesStore((state) => state.comments[postCid]); const address = comment?.author?.address; const getAnonAddressForReply = useCallback(async () => { @@ -189,7 +191,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, postCid, scrollY, s const location = useLocation(); const isInAllView = isAllView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); - const subplebbit = useSubplebbit({ subplebbitAddress }); + const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]); const { updatedAt } = subplebbit || {}; const isBoardOffline = subplebbit?.updatedAt && subplebbit.updatedAt < Date.now() / 1000 - 60 * 60; const offlineAlert = updatedAt diff --git a/src/components/subplebbit-stats/subplebbit-stats.tsx b/src/components/subplebbit-stats/subplebbit-stats.tsx index 16d9ed9c..de712dff 100644 --- a/src/components/subplebbit-stats/subplebbit-stats.tsx +++ b/src/components/subplebbit-stats/subplebbit-stats.tsx @@ -1,6 +1,8 @@ import { useLocation, useParams } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; -import { useAccountComment, useComment, useSubplebbit, useSubplebbitStats } from '@plebbit/plebbit-react-hooks'; +import { useAccountComment, useSubplebbitStats } from '@plebbit/plebbit-react-hooks'; +import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits'; +import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages'; import useSubplebbitStatsVisibilityStore from '../../stores/use-subplebbit-stats-visibility-store'; import { isDescriptionView, isRulesView } from '../../lib/utils/view-utils'; import styles from './subplebbit-stats.module.css'; @@ -12,7 +14,7 @@ const SubplebbitStats = () => { const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any }); const subplebbitAddress = params?.subplebbitAddress || accountComment?.subplebbitAddress; - const subplebbit = useSubplebbit({ subplebbitAddress }); + const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]); const { address, createdAt } = subplebbit || {}; let stats = useSubplebbitStats({ subplebbitAddress: address }); @@ -23,7 +25,7 @@ const SubplebbitStats = () => { const isInDescriptionView = isDescriptionView(location.pathname, params); const isInRulesView = isRulesView(location.pathname, params); - const comment = useComment({ commentCid: params?.commentCid }); + const comment = useSubplebbitsPagesStore((state) => state.comments[params?.commentCid as string]); const { deleted, locked, removed } = comment || {}; const hideStats = deleted || locked || removed || isInDescriptionView || isInRulesView; diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index f56f0158..8a3fe8a0 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Comment, Role, useComment, useEditedComment, useSubplebbit } from '@plebbit/plebbit-react-hooks'; +import { Comment, Role, useComment, useEditedComment } from '@plebbit/plebbit-react-hooks'; import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits'; import { useLocation, useParams } from 'react-router-dom'; import { isAllView, isDescriptionView, isRulesView } from '../../lib/utils/view-utils'; @@ -57,7 +57,7 @@ const PostPage = () => { const isInDescriptionView = isDescriptionView(location.pathname, params); const isInRulesView = isRulesView(location.pathname, params); - const subplebbit = useSubplebbit({ subplebbitAddress }); + const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress as string]); const { createdAt, description, rules, shortAddress, suggested, title } = subplebbit; const comment = useComment({ commentCid }); From 23f70c96b838b8a4f1ce37c28fe13a4ed9f0ad23 Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Thu, 6 Mar 2025 15:40:11 +0100 Subject: [PATCH 06/18] Update topbar.tsx --- src/components/topbar/topbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/topbar/topbar.tsx b/src/components/topbar/topbar.tsx index d148e1d4..64a8b9a0 100644 --- a/src/components/topbar/topbar.tsx +++ b/src/components/topbar/topbar.tsx @@ -144,7 +144,7 @@ const TopBarMobile = ({ subplebbitAddress }: { subplebbitAddress: string }) => { const params = useParams(); const isInAllView = isAllView(location.pathname); const isInCatalogView = isCatalogView(location.pathname, params); - const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); + const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const selectValue = isInAllView ? 'all' : isInSubscriptionsView ? 'subscriptions' : subplebbitAddress; const { accountSubplebbits } = useAccountSubplebbits(); @@ -160,7 +160,7 @@ const TopBarMobile = ({ subplebbitAddress }: { subplebbitAddress: string }) => { const subplebbitAddress = address?.includes('.') ? address : Plebbit.getShortAddress(address); return ( ); })} From cf183393a4d2a011025704c8386d425d6286dce0 Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Thu, 6 Mar 2025 15:44:26 +0100 Subject: [PATCH 07/18] update board buttons UI, add catalog search UI --- src/app.tsx | 3 +- .../board-buttons/board-buttons.module.css | 1 - .../board-buttons/board-buttons.tsx | 26 +++---- .../catalog-filters.module.css | 0 .../catalog-filters/catalog-filters.tsx | 4 +- .../catalog-filters/index.ts | 0 .../catalog-search/catalog-search.module.css | 49 ++++++++++++ .../catalog-search/catalog-search.tsx | 45 +++++++++++ src/components/catalog-search/index.ts | 1 + src/components/post-form/post-form.module.css | 3 +- src/stores/use-catalog-filters-store.ts | 74 +++++++++++++++++-- src/views/catalog/catalog.tsx | 45 ++++++++--- 12 files changed, 213 insertions(+), 38 deletions(-) rename src/{views/catalog => components}/catalog-filters/catalog-filters.module.css (100%) rename src/{views/catalog => components}/catalog-filters/catalog-filters.tsx (96%) rename src/{views/catalog => components}/catalog-filters/index.ts (100%) create mode 100644 src/components/catalog-search/catalog-search.module.css create mode 100644 src/components/catalog-search/catalog-search.tsx create mode 100644 src/components/catalog-search/index.ts diff --git a/src/app.tsx b/src/app.tsx index 3843ee3b..b3810f16 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -55,8 +55,9 @@ const BoardLayout = () => { {isMobile ? (subplebbitAddress || isInAllView || isInSubscriptionsView || pendingPost?.subplebbitAddress) && ( <> - +
+ ) : (subplebbitAddress || isInAllView || isInSubscriptionsView || pendingPost?.subplebbitAddress) && ( diff --git a/src/components/board-buttons/board-buttons.module.css b/src/components/board-buttons/board-buttons.module.css index 43068ba4..59d754e4 100644 --- a/src/components/board-buttons/board-buttons.module.css +++ b/src/components/board-buttons/board-buttons.module.css @@ -51,7 +51,6 @@ } .desktopBoardButtons { - padding-right: 5px; text-transform: capitalize; } diff --git a/src/components/board-buttons/board-buttons.tsx b/src/components/board-buttons/board-buttons.tsx index 073a504d..320a414c 100644 --- a/src/components/board-buttons/board-buttons.tsx +++ b/src/components/board-buttons/board-buttons.tsx @@ -4,17 +4,18 @@ import { useAccountComment, useSubscribe } from '@plebbit/plebbit-react-hooks'; import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits'; import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages'; import { isAllView, isCatalogView, isDescriptionView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; +import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; import useCatalogStyleStore from '../../stores/use-catalog-style-store'; import useFeedResetStore from '../../stores/use-feed-reset-store'; import useSortingStore from '../../stores/use-sorting-store'; -import useTimeFilter from '../../hooks/use-time-filter'; -import CatalogFilters from '../../views/catalog/catalog-filters/'; -import styles from './board-buttons.module.css'; -import Tooltip from '../tooltip'; import useCountLinksInReplies from '../../hooks/use-count-links-in-replies'; -import _ from 'lodash'; import useIsMobile from '../../hooks/use-is-mobile'; -import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; +import useTimeFilter from '../../hooks/use-time-filter'; +import CatalogFilters from '../catalog-filters'; +import CatalogSearch from '../catalog-search'; +import Tooltip from '../tooltip'; +import styles from './board-buttons.module.css'; +import _ from 'lodash'; interface BoardButtonsProps { address?: string | undefined; @@ -282,7 +283,7 @@ export const MobileBoardButtons = () => {
- +
@@ -380,17 +381,16 @@ export const DesktopBoardButtons = () => { {(isInAllView || isInSubscriptionsView) && ( )} - {isInCatalogView && ( - <> - [ - ] - - )}{' '} {!(isInAllView || isInSubscriptionsView) && ( <> [ ] + )}{' '} + {isInCatalogView && ( + <> + [] + )}
diff --git a/src/views/catalog/catalog-filters/catalog-filters.module.css b/src/components/catalog-filters/catalog-filters.module.css similarity index 100% rename from src/views/catalog/catalog-filters/catalog-filters.module.css rename to src/components/catalog-filters/catalog-filters.module.css diff --git a/src/views/catalog/catalog-filters/catalog-filters.tsx b/src/components/catalog-filters/catalog-filters.tsx similarity index 96% rename from src/views/catalog/catalog-filters/catalog-filters.tsx rename to src/components/catalog-filters/catalog-filters.tsx index fc403549..188ec343 100644 --- a/src/views/catalog/catalog-filters/catalog-filters.tsx +++ b/src/components/catalog-filters/catalog-filters.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import useCatalogFiltersStore from '../../../stores/use-catalog-filters-store'; +import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; import styles from './catalog-filters.module.css'; const FiltersTable = ({ onSave }: { onSave: () => void }) => { @@ -41,6 +41,8 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { enabled: true, count: 0, filteredCids: new Set(), + subplebbitCounts: new Map(), + subplebbitFilteredCids: new Map>(), hide: true, top: false, }, diff --git a/src/views/catalog/catalog-filters/index.ts b/src/components/catalog-filters/index.ts similarity index 100% rename from src/views/catalog/catalog-filters/index.ts rename to src/components/catalog-filters/index.ts diff --git a/src/components/catalog-search/catalog-search.module.css b/src/components/catalog-search/catalog-search.module.css new file mode 100644 index 00000000..6c492561 --- /dev/null +++ b/src/components/catalog-search/catalog-search.module.css @@ -0,0 +1,49 @@ +.button { + text-transform: capitalize; + color: var(--button-desktop-text-color); + text-decoration: var(--button-text-decoration); +} + +.button:hover { + color: var(--button-desktop-text-color-hover); + cursor: pointer; +} + +.filtersButton { + text-transform: capitalize; +} + +.searchContainer { + display: inline; +} + +.searchContainer input { + width: 75px; + margin: 0px 5px; + padding: 1px; + outline: none; + border: 1px solid #AAA; + font-size: 11px; +} + +.searchContainer input:focus { + border: var(--post-form-field-input-focus-border); +} + +.closeSearch { + cursor: pointer; + border: none; + white-space: nowrap; + color: var(--button-desktop-text-color); +} + +.closeSearch:hover { + color: var(--button-desktop-text-color-hover); +} + +@media (max-width: 640px) { + .searchContainer input { + width: 75px; + font-size: 16px; + } +} diff --git a/src/components/catalog-search/catalog-search.tsx b/src/components/catalog-search/catalog-search.tsx new file mode 100644 index 00000000..7c723dfe --- /dev/null +++ b/src/components/catalog-search/catalog-search.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './catalog-search.module.css'; +import useIsMobile from '../../hooks/use-is-mobile'; + +const CatalogSearch = () => { + const { t } = useTranslation(); + const [openSearch, setOpenSearch] = useState(false); + + useEffect(() => { + if (openSearch) { + const input = document.querySelector('input'); + input?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpenSearch(false); + } + }; + window.addEventListener('keydown', handleKeyDown); + } + }, [openSearch]); + + const isMobile = useIsMobile(); + + return ( + <> + {!isMobile && '['} + setOpenSearch(!openSearch)}> + {t('search')} + + {!isMobile && ']'} + {openSearch && ( +
+ + setOpenSearch(false)}> + ✖ + +
+ )} + + ); +}; + +export default CatalogSearch; diff --git a/src/components/catalog-search/index.ts b/src/components/catalog-search/index.ts new file mode 100644 index 00000000..aa2ace2c --- /dev/null +++ b/src/components/catalog-search/index.ts @@ -0,0 +1 @@ +export { default } from './catalog-search'; diff --git a/src/components/post-form/post-form.module.css b/src/components/post-form/post-form.module.css index 7dca9193..649c43c1 100644 --- a/src/components/post-form/post-form.module.css +++ b/src/components/post-form/post-form.module.css @@ -15,8 +15,7 @@ .postFormMobile { text-align: center; - margin-top: -2px; - padding-bottom: 7px; + margin-top: -3px; } .closed { diff --git a/src/stores/use-catalog-filters-store.ts b/src/stores/use-catalog-filters-store.ts index 0d05f31b..1b7d9728 100644 --- a/src/stores/use-catalog-filters-store.ts +++ b/src/stores/use-catalog-filters-store.ts @@ -7,6 +7,8 @@ interface FilterItem { enabled: boolean; count: number; filteredCids: Set; + subplebbitCounts: Map; + subplebbitFilteredCids: Map>; hide: boolean; top: boolean; } @@ -24,8 +26,11 @@ interface CatalogFiltersStore { initializeFilter: () => void; filteredCount: number; filteredCids: Set; - incrementFilterCount: (filterIndex: number, cid: string) => void; + incrementFilterCount: (filterIndex: number, cid: string, subplebbitAddress: string) => void; recalcFilteredCount: () => void; + currentSubplebbitAddress: string | null; + setCurrentSubplebbitAddress: (address: string | null) => void; + getFilteredCountForCurrentSubplebbit: () => number; } const useCatalogFiltersStore = create( @@ -41,6 +46,8 @@ const useCatalogFiltersStore = create( filterItems: [], filteredCount: 0, filteredCids: new Set(), + currentSubplebbitAddress: null, + setCurrentSubplebbitAddress: (address: string | null) => set({ currentSubplebbitAddress: address }), setFilterItems: (items: FilterItem[]) => { const nonEmptyItems = items .filter((item) => item.text.trim() !== '') @@ -48,6 +55,8 @@ const useCatalogFiltersStore = create( ...item, count: item.count || 0, filteredCids: item.filteredCids || new Set(), + subplebbitCounts: item.subplebbitCounts || new Map(), + subplebbitFilteredCids: item.subplebbitFilteredCids || new Map(), hide: item.hide ?? true, top: item.top ?? false, })); @@ -61,6 +70,8 @@ const useCatalogFiltersStore = create( ...item, count: item.count || 0, filteredCids: item.filteredCids || new Set(), + subplebbitCounts: item.subplebbitCounts || new Map(), + subplebbitFilteredCids: item.subplebbitFilteredCids || new Map(), hide: item.hide ?? true, top: item.top ?? false, })); @@ -98,30 +109,75 @@ const useCatalogFiltersStore = create( initializeFilter: () => { get().updateFilter(); }, - incrementFilterCount: (filterIndex: number, cid: string) => { + incrementFilterCount: (filterIndex: number, cid: string, subplebbitAddress: string) => { set((state) => { const newFilterItems = [...state.filterItems]; if (newFilterItems[filterIndex]) { const item = newFilterItems[filterIndex]; - if (!item.filteredCids.has(cid)) { + + const subplebbitFilteredCids = new Map(item.subplebbitFilteredCids); + if (!subplebbitFilteredCids.has(subplebbitAddress)) { + subplebbitFilteredCids.set(subplebbitAddress, new Set()); + } + const cidSet = subplebbitFilteredCids.get(subplebbitAddress)!; + + if (!cidSet.has(cid)) { const newItemFilteredCids = new Set(item.filteredCids); newItemFilteredCids.add(cid); + + cidSet.add(cid); + subplebbitFilteredCids.set(subplebbitAddress, cidSet); + + const subplebbitCounts = new Map(item.subplebbitCounts); + const currentCount = subplebbitCounts.get(subplebbitAddress) || 0; + subplebbitCounts.set(subplebbitAddress, currentCount + 1); + newFilterItems[filterIndex] = { ...item, count: item.count + 1, filteredCids: newItemFilteredCids, + subplebbitCounts, + subplebbitFilteredCids, }; - const newFilteredCount = newFilterItems.reduce((sum, filt) => (filt.hide ? sum + filt.count : sum), 0); - return { filterItems: newFilterItems, filteredCount: newFilteredCount }; + + return { filterItems: newFilterItems }; } } return state; }); + + get().recalcFilteredCount(); }, recalcFilteredCount: () => { - set((state) => ({ - filteredCount: state.filterItems.reduce((sum, item) => (item.hide ? sum + item.count : sum), 0), - })); + set((state) => { + const currentSubplebbit = state.currentSubplebbitAddress; + if (!currentSubplebbit) return { filteredCount: 0 }; + + let filteredCount = 0; + for (const item of state.filterItems) { + if (item.enabled && item.hide) { + const subCount = item.subplebbitCounts?.get(currentSubplebbit) || 0; + filteredCount += subCount; + } + } + + return { filteredCount }; + }); + }, + getFilteredCountForCurrentSubplebbit: () => { + const state = get(); + const currentSubplebbit = state.currentSubplebbitAddress; + if (!currentSubplebbit) return 0; + + let filteredCount = 0; + for (const item of state.filterItems) { + if (item.enabled && item.hide) { + const subCount = item.subplebbitCounts?.get(currentSubplebbit) || 0; + filteredCount += subCount; + } + } + + return filteredCount; }, }), { @@ -134,6 +190,8 @@ const useCatalogFiltersStore = create( enabled: item.enabled, hide: item.hide, top: item.top, + subplebbitCounts: Array.from(item.subplebbitCounts || new Map()), + subplebbitFilteredCids: Array.from((item.subplebbitFilteredCids || new Map()).entries()).map(([key, value]) => [key, Array.from(value)]), })), } as any; }, diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx index 16f8da82..64b60d83 100644 --- a/src/views/catalog/catalog.tsx +++ b/src/views/catalog/catalog.tsx @@ -23,7 +23,8 @@ const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; const createContentFilter = ( filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set; hide: boolean; top: boolean }[], - onFilterMatch?: (filterIndex: number, cid: string) => void, + subplebbitAddress: string, + onFilterMatch?: (filterIndex: number, cid: string, subplebbitAddress: string) => void, ) => { // Create a unique key based on the enabled filter items const enabledFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== ''); @@ -51,10 +52,10 @@ const createContentFilter = ( const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled); if (filterIndex !== -1) { if (onFilterMatch) { - onFilterMatch(filterIndex, comment.cid); + onFilterMatch(filterIndex, comment.cid, subplebbitAddress); } else { // Fallback to the store method if no callback provided - useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, comment.cid); + useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, comment.cid, subplebbitAddress); } } @@ -91,10 +92,11 @@ const createImageFilter = (showTextOnlyThreads: boolean) => { const createCombinedFilter = ( showTextOnlyThreads: boolean, filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set; hide: boolean; top: boolean }[], - onFilterMatch?: (filterIndex: number, cid: string) => void, + subplebbitAddress: string, + onFilterMatch?: (filterIndex: number, cid: string, subplebbitAddress: string) => void, ) => { const imageFilter = createImageFilter(showTextOnlyThreads); - const contentFilter = createContentFilter(filterItems, onFilterMatch); + const contentFilter = createContentFilter(filterItems, subplebbitAddress, onFilterMatch); return { filter: (comment: Comment) => { @@ -152,17 +154,25 @@ const Catalog = () => { const { timeFilterSeconds, timeFilterName } = useTimeFilter(); const { sortType } = useSortingStore(); - // stable callback for filter matching - const handleFilterMatch = useCallback((filterIndex: number, cid: string) => { - useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, cid); + // Create a stable callback for filter matching + const handleFilterMatch = useCallback((filterIndex: number, cid: string, subplebbitAddress: string) => { + useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, cid, subplebbitAddress); }, []); + // Set the current subplebbit address + useEffect(() => { + useCatalogFiltersStore.getState().setCurrentSubplebbitAddress(subplebbitAddress || null); + return () => { + useCatalogFiltersStore.getState().setCurrentSubplebbitAddress(null); + }; + }, [subplebbitAddress]); + const feedOptions = useMemo(() => { const options: any = { subplebbitAddresses, sortType, postsPerPage: isInAllView || isInSubscriptionsView ? 10 : postsPerPage, - filter: createCombinedFilter(showTextOnlyThreads, filterItems, handleFilterMatch), + filter: createCombinedFilter(showTextOnlyThreads, filterItems, subplebbitAddress || 'all', handleFilterMatch), }; if (isInAllView || isInSubscriptionsView) { @@ -170,7 +180,18 @@ const Catalog = () => { } return options; - }, [subplebbitAddresses, sortType, isInAllView, isInSubscriptionsView, postsPerPage, timeFilterSeconds, showTextOnlyThreads, filterItems, handleFilterMatch]); + }, [ + subplebbitAddresses, + sortType, + isInAllView, + isInSubscriptionsView, + postsPerPage, + timeFilterSeconds, + showTextOnlyThreads, + filterItems, + subplebbitAddress, + handleFilterMatch, + ]); const { feed, hasMore, loadMore, reset, subplebbitAddressesWithNewerPosts } = useFeed(feedOptions); const { accountComments } = useAccountComments(); @@ -226,14 +247,14 @@ const Catalog = () => { subplebbitAddresses, sortType, newerThan: 60 * 60 * 24 * 7, - filter: createCombinedFilter(showTextOnlyThreads, filterItems, handleFilterMatch), + filter: createCombinedFilter(showTextOnlyThreads, filterItems, subplebbitAddress || 'all', handleFilterMatch), }); const { feed: monthlyFeed } = useFeed({ subplebbitAddresses, sortType, newerThan: 60 * 60 * 24 * 30, - filter: createCombinedFilter(showTextOnlyThreads, filterItems, handleFilterMatch), + filter: createCombinedFilter(showTextOnlyThreads, filterItems, subplebbitAddress || 'all', handleFilterMatch), }); const [showMorePostsSuggestion, setShowMorePostsSuggestion] = useState(false); From a8f7de7d85dcf33adec0b0a4e8f17a42949b5f4a Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Thu, 6 Mar 2025 16:27:09 +0100 Subject: [PATCH 08/18] feat(catalog): add search --- public/translations/ar/default.json | 3 +- public/translations/bn/default.json | 3 +- public/translations/cs/default.json | 3 +- public/translations/da/default.json | 3 +- public/translations/de/default.json | 3 +- public/translations/el/default.json | 3 +- public/translations/en/default.json | 3 +- public/translations/es/default.json | 3 +- public/translations/fa/default.json | 3 +- public/translations/fi/default.json | 3 +- public/translations/fil/default.json | 3 +- public/translations/fr/default.json | 3 +- public/translations/he/default.json | 3 +- public/translations/hi/default.json | 3 +- public/translations/hu/default.json | 3 +- public/translations/id/default.json | 3 +- public/translations/it/default.json | 3 +- public/translations/ja/default.json | 3 +- public/translations/ko/default.json | 3 +- public/translations/mr/default.json | 3 +- public/translations/nl/default.json | 3 +- public/translations/no/default.json | 3 +- public/translations/pl/default.json | 3 +- public/translations/pt/default.json | 3 +- public/translations/ro/default.json | 3 +- public/translations/ru/default.json | 3 +- public/translations/sq/default.json | 3 +- public/translations/sv/default.json | 3 +- public/translations/te/default.json | 3 +- public/translations/th/default.json | 3 +- public/translations/tr/default.json | 3 +- public/translations/uk/default.json | 3 +- public/translations/ur/default.json | 3 +- public/translations/vi/default.json | 3 +- public/translations/zh/default.json | 3 +- .../board-buttons/board-buttons.tsx | 28 +++++++-- .../catalog-search/catalog-search.tsx | 60 +++++++++++++++++-- src/hooks/use-catalog-feed-rows.ts | 6 +- src/stores/use-catalog-filters-store.ts | 23 +++++++ src/views/catalog/catalog.tsx | 49 +++++++++++---- src/views/post/post.module.css | 2 +- 41 files changed, 213 insertions(+), 60 deletions(-) diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index c303f9a9..d1efbc9c 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "تم حذف هذه الرد من قبل مؤلفه", "this_reply_was_removed": "تمت إزالة هذه الرد من قبل مشرفي المنتدى", "not_mod_of_any_board": "أنت لست مشرفًا في أي لوحة.", - "filters_and_highlights": "الفلاتر والإبرازات" + "filters_and_highlights": "الفلاتر والإبرازات", + "search_results_for": "نتائج البحث عن" } \ No newline at end of file diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index db35d423..83602f2a 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "এই প্রতিক্রিয়া তার লেখক দ্বারা মুছে ফেলা হয়েছে", "this_reply_was_removed": "এই প্রতিক্রিয়া বোর্ড পরিচালকদের দ্বারা সরানো হয়েছে", "not_mod_of_any_board": "আপনি কোনও বোর্ডের মড নন।", - "filters_and_highlights": "ফিল্টার এবং হাইলাইট" + "filters_and_highlights": "ফিল্টার এবং হাইলাইট", + "search_results_for": "অনুসন্ধান ফলাফল" } \ No newline at end of file diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index 8342b63c..513d6992 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Tato odpověď byla smazána autorem", "this_reply_was_removed": "Tato odpověď byla odstraněna moderátory fóra", "not_mod_of_any_board": "Nejste moderátor žádné desky.", - "filters_and_highlights": "Filtry a zvýraznění" + "filters_and_highlights": "Filtry a zvýraznění", + "search_results_for": "Výsledky hledání pro" } \ No newline at end of file diff --git a/public/translations/da/default.json b/public/translations/da/default.json index 3011fcd0..3def0138 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Dette svar blev slettet af forfatteren", "this_reply_was_removed": "Dette svar blev fjernet af forummoderatorer", "not_mod_of_any_board": "Du er ikke moderator for nogen tavler.", - "filters_and_highlights": "Filtre og fremhævninger" + "filters_and_highlights": "Filtre og fremhævninger", + "search_results_for": "Søgeresultater for" } \ No newline at end of file diff --git a/public/translations/de/default.json b/public/translations/de/default.json index aa677e1e..9750d5a4 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Diese Antwort wurde vom Autor gelöscht", "this_reply_was_removed": "Diese Antwort wurde von den Forenmoderatoren entfernt", "not_mod_of_any_board": "Du bist kein Moderator eines Boards.", - "filters_and_highlights": "Filter & Highlights" + "filters_and_highlights": "Filter & Highlights", + "search_results_for": "Suchergebnisse für" } \ No newline at end of file diff --git a/public/translations/el/default.json b/public/translations/el/default.json index 7ba81d49..0a1c369c 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Αυτή η απάντηση διαγράφηκε από τον συγγραφέα της", "this_reply_was_removed": "Η απάντηση αυτή αφαιρέθηκε από τους διαχειριστές του φόρουμ", "not_mod_of_any_board": "Δεν είστε διαχειριστής σε καμία κοινότητα.", - "filters_and_highlights": "Φίλτρα και επισημάνσεις" + "filters_and_highlights": "Φίλτρα και επισημάνσεις", + "search_results_for": "Αποτελέσματα αναζήτησης για" } \ No newline at end of file diff --git a/public/translations/en/default.json b/public/translations/en/default.json index 5e2b97ba..f17a13c5 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "This reply was deleted by its author", "this_reply_was_removed": "This reply was removed by board moderators", "not_mod_of_any_board": "You are not a mod of any board.", - "filters_and_highlights": "Filters & Highlights" + "filters_and_highlights": "Filters & Highlights", + "search_results_for": "Search results for" } \ No newline at end of file diff --git a/public/translations/es/default.json b/public/translations/es/default.json index ac2e4202..ea303d00 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Esta respuesta fue eliminada por su autor", "this_reply_was_removed": "Esta respuesta fue eliminada por los moderadores del foro", "not_mod_of_any_board": "No eres moderador de ningún tablero.", - "filters_and_highlights": "Filtros y resaltar" + "filters_and_highlights": "Filtros y resaltar", + "search_results_for": "Resultados de búsqueda para" } \ No newline at end of file diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index d49a7da3..fb331102 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "این پاسخ توسط نویسنده‌اش حذف شد", "this_reply_was_removed": "این پاسخ توسط مدیران انجمن حذف شد", "not_mod_of_any_board": "شما هیچ تابلو مدیر نیستید.", - "filters_and_highlights": "فیلترها و هایلایت‌ها" + "filters_and_highlights": "فیلترها و هایلایت‌ها", + "search_results_for": "نتایج جستجو برای" } \ No newline at end of file diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index 529bcb6c..2e66654c 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Tämä vastaus poistettiin sen kirjoittajalta", "this_reply_was_removed": "Tämä vastaus poistettiin foorumin moderaattoreiden toimesta", "not_mod_of_any_board": "Et ole moderaattori missään taulussa.", - "filters_and_highlights": "Suodattimet ja korostukset" + "filters_and_highlights": "Suodattimet ja korostukset", + "search_results_for": "Hakutulokset hakusanalle" } \ No newline at end of file diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index 2b38665d..d233af04 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Ang tugon na ito ay tinanggal ng may-akda nito", "this_reply_was_removed": "Ang sagot na ito ay tinanggal ng mga moderator ng board", "not_mod_of_any_board": "Hindi ka mod ng anumang board.", - "filters_and_highlights": "Mga filter at highlight" + "filters_and_highlights": "Mga filter at highlight", + "search_results_for": "Mga resulta ng paghahanap para sa" } \ No newline at end of file diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index 2a823f4f..98b51f6a 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Cette réponse a été supprimée par son auteur", "this_reply_was_removed": "Cette réponse a été supprimée par les modérateurs du forum", "not_mod_of_any_board": "Vous n'êtes pas modérateur de n'aucun forum.", - "filters_and_highlights": "Filtres et mises en évidence" + "filters_and_highlights": "Filtres et mises en évidence", + "search_results_for": "Résultats de recherche pour" } \ No newline at end of file diff --git a/public/translations/he/default.json b/public/translations/he/default.json index fd493968..337312cc 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "תשובה זו נמחקה על ידי המחבר שלה", "this_reply_was_removed": "תשובה זו הוסרה על ידי מנהלי הפורום", "not_mod_of_any_board": "אתה לא מוד בכל לוח.", - "filters_and_highlights": "סינונים והדגשות" + "filters_and_highlights": "סינונים והדגשות", + "search_results_for": "תוצאות חיפוש עבור" } \ No newline at end of file diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index 2b659d1c..423665e1 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "इस उत्तर को इसके लेखक द्वारा हटा दिया गया था", "this_reply_was_removed": "यह उत्तर बोर्ड मॉडरेटर द्वारा हटा दिया गया था", "not_mod_of_any_board": "आप किसी बोर्ड के मॉड नहीं हैं।", - "filters_and_highlights": "फ़िल्टर और हाइलाइट्स" + "filters_and_highlights": "फ़िल्टर और हाइलाइट्स", + "search_results_for": "के लिए खोज परिणाम" } \ No newline at end of file diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index 325ebb3d..76500163 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Ez a válasz törölve lett a szerzője által", "this_reply_was_removed": "Ez a válasz törlésre került a fórum moderátorai által", "not_mod_of_any_board": "Nem vagy moderátor egyetlen táblán sem.", - "filters_and_highlights": "Szűrők és kiemelések" + "filters_and_highlights": "Szűrők és kiemelések", + "search_results_for": "Keresési eredmények" } \ No newline at end of file diff --git a/public/translations/id/default.json b/public/translations/id/default.json index 6e344b03..8e6ba818 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Balasan ini dihapus oleh penulisnya", "this_reply_was_removed": "Balasan ini dihapus oleh moderator papan", "not_mod_of_any_board": "Anda bukan mod di papan manapun.", - "filters_and_highlights": "Filter dan Sorotan" + "filters_and_highlights": "Filter dan Sorotan", + "search_results_for": "Hasil pencarian untuk" } \ No newline at end of file diff --git a/public/translations/it/default.json b/public/translations/it/default.json index cf016b54..483ed455 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Questa risposta è stata eliminata dal suo autore", "this_reply_was_removed": "Questa risposta è stata rimossa dai moderatori del forum", "not_mod_of_any_board": "Non sei un mod di nessuna board.", - "filters_and_highlights": "Filtri e Evidenziazioni" + "filters_and_highlights": "Filtri e Evidenziazioni", + "search_results_for": "Risultati di ricerca per" } \ No newline at end of file diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index eff04089..ecef105c 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "この返信はその著者によって削除されました", "this_reply_was_removed": "この返信は掲示板のモデレーターによって削除されました", "not_mod_of_any_board": "あなたはどのボードのモデレーターでもありません。", - "filters_and_highlights": "フィルターとハイライト" + "filters_and_highlights": "フィルターとハイライト", + "search_results_for": "検索結果" } \ No newline at end of file diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index 8fcfdca8..12a791f3 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "이 답글은 작성자에 의해 삭제되었습니다", "this_reply_was_removed": "이 답글은 게시판 관리자에 의해 삭제되었습니다", "not_mod_of_any_board": "당신은 어떤 게시판의 관리자도 아닙니다.", - "filters_and_highlights": "필터 및 하이라이트" + "filters_and_highlights": "필터 및 하이라이트", + "search_results_for": "검색 결과" } \ No newline at end of file diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index 4d5d1a05..d8c983cb 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "हा प्रतिसाद त्याच्या लेखकाने हटवला आहे", "this_reply_was_removed": "या उत्तराला बोर्ड मॉडरेटरने काढले आहे", "not_mod_of_any_board": "तुम्ही कोणत्याही बोर्डचे मॉड नाहीत.", - "filters_and_highlights": "फिल्टर आणि हायलाइट्स" + "filters_and_highlights": "फिल्टर आणि हायलाइट्स", + "search_results_for": "सर्च परिणाम" } \ No newline at end of file diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index 66e004d0..933ff4e5 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Dit antwoord werd verwijderd door de auteur", "this_reply_was_removed": "Dit antwoord is verwijderd door de forum moderators", "not_mod_of_any_board": "Je bent geen mod van een bord.", - "filters_and_highlights": "Filters en markeringen" + "filters_and_highlights": "Filters en markeringen", + "search_results_for": "Zoekresultaten voor" } \ No newline at end of file diff --git a/public/translations/no/default.json b/public/translations/no/default.json index 957e4e5e..d2ff1f21 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Dette svaret ble slettet av forfatteren", "this_reply_was_removed": "Dette svaret ble fjernet av forummoderatorer", "not_mod_of_any_board": "Du er ikke moderator på noen brett.", - "filters_and_highlights": "Filtre og fremhevinger" + "filters_and_highlights": "Filtre og fremhevinger", + "search_results_for": "Søkeresultater for" } \ No newline at end of file diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index a282392e..e1299990 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Ta odpowiedź została usunięta przez autora", "this_reply_was_removed": "Ta odpowiedź została usunięta przez moderatorów forum", "not_mod_of_any_board": "Nie jesteś moderatorem żadnej tablicy.", - "filters_and_highlights": "Filtry i wyróżnienia" + "filters_and_highlights": "Filtry i wyróżnienia", + "search_results_for": "Wyniki wyszukiwania dla" } \ No newline at end of file diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index 7227a583..36fed0ef 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Esta resposta foi excluída pelo autor", "this_reply_was_removed": "Esta resposta foi removida pelos moderadores do fórum", "not_mod_of_any_board": "Você não é um moderador de nenhum quadro.", - "filters_and_highlights": "Filtros e Destaques" + "filters_and_highlights": "Filtros e Destaques", + "search_results_for": "Resultados de pesquisa para" } \ No newline at end of file diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index bb3afd18..70bd1821 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Acest răspuns a fost șters de autorul său", "this_reply_was_removed": "Acest răspuns a fost eliminat de moderatorii forumului", "not_mod_of_any_board": "Nu ești moderator pe niciun tablou.", - "filters_and_highlights": "Filtre și evidențiere" + "filters_and_highlights": "Filtre și evidențiere", + "search_results_for": "Rezultatele căutării pentru" } \ No newline at end of file diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index 1845e5e9..950de4d8 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Этот ответ был удален автором", "this_reply_was_removed": "Этот ответ был удален модераторами форума", "not_mod_of_any_board": "Вы не модератор ни одной доски.", - "filters_and_highlights": "Фильтры и выделения" + "filters_and_highlights": "Фильтры и выделения", + "search_results_for": "Результаты поиска для" } \ No newline at end of file diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index 30c031a8..6752e2b9 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Ky përgjigje u fshi nga autori i saj", "this_reply_was_removed": "Ky përgjigje u hoq nga moderatorët e forumit", "not_mod_of_any_board": "Nuk jeni mod në asnjë bord.", - "filters_and_highlights": "Filtra dhe theksime" + "filters_and_highlights": "Filtra dhe theksime", + "search_results_for": "Rezultatet e kërkimit për" } \ No newline at end of file diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index c3220d2c..0c3bccb3 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Detta svar togs bort av författaren", "this_reply_was_removed": "Detta svar togs bort av forumets moderatorer", "not_mod_of_any_board": "Du är inte moderator på någon tavla.", - "filters_and_highlights": "Filter och markeringar" + "filters_and_highlights": "Filter och markeringar", + "search_results_for": "Sökresultat för" } \ No newline at end of file diff --git a/public/translations/te/default.json b/public/translations/te/default.json index 77071d70..b0aec7b8 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "ఈ స్పందనను దాని రచయిత తొలగించారు", "this_reply_was_removed": "ఈ స్పందన బోర్డ్ మోడరేటర్లచే తొలగించబడింది", "not_mod_of_any_board": "మీరు ఎటువంటి బోర్డుకు మోడ్ కంటే లేరు.", - "filters_and_highlights": "ఫిల్టర్లు మరియు హైలైట్‌లు" + "filters_and_highlights": "ఫిల్టర్లు మరియు హైలైట్‌లు", + "search_results_for": "శోధన ఫలితాలు" } \ No newline at end of file diff --git a/public/translations/th/default.json b/public/translations/th/default.json index 424791e3..92adbe11 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "การตอบกลับนี้ถูกลบโดยผู้แต่ง", "this_reply_was_removed": "คำตอบนี้ถูกลบโดยผู้ดูแลบอร์ด", "not_mod_of_any_board": "คุณไม่ใช่ผู้ดูแลบอร์ดใดๆ", - "filters_and_highlights": "ตัวกรองและไฮไลท์" + "filters_and_highlights": "ตัวกรองและไฮไลท์", + "search_results_for": "ผลการค้นหาสำหรับ" } \ No newline at end of file diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index be858ec5..1f74ba69 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Bu yanıt, yazar tarafından silindi", "this_reply_was_removed": "Bu yanıt, forum moderatörleri tarafından kaldırıldı", "not_mod_of_any_board": "Hiçbir tahtanın moderatörü değilsiniz.", - "filters_and_highlights": "Filtreler ve Vurgular" + "filters_and_highlights": "Filtreler ve Vurgular", + "search_results_for": "Arama sonuçları için" } \ No newline at end of file diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index d4521437..2414bdee 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Цю відповідь було видалено її автором", "this_reply_was_removed": "Цю відповідь було видалено модераторами форуму", "not_mod_of_any_board": "Ви не модератор жодної дошки.", - "filters_and_highlights": "Фільтри та виділення" + "filters_and_highlights": "Фільтри та виділення", + "search_results_for": "Результати пошуку для" } \ No newline at end of file diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index 2100d08a..93e8c58d 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "اس جواب کو اس کے مصنف نے حذف کر دیا تھا", "this_reply_was_removed": "اس جواب کو بورڈ کے منتظمین نے حذف کر دیا ہے", "not_mod_of_any_board": "آپ کسی بورڈ کے موڈ نہیں ہیں۔", - "filters_and_highlights": "فلٹرز اور ہائیلائٹس" + "filters_and_highlights": "فلٹرز اور ہائیلائٹس", + "search_results_for": "تلاش کے نتائج" } \ No newline at end of file diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index acd7aed7..010e1085 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "Câu trả lời này đã bị tác giả của nó xóa", "this_reply_was_removed": "Phản hồi này đã bị xóa bởi các quản trị viên diễn đàn", "not_mod_of_any_board": "Bạn không phải là quản trị viên của bất kỳ bảng nào.", - "filters_and_highlights": "Bộ lọc và Nổi bật" + "filters_and_highlights": "Bộ lọc và Nổi bật", + "search_results_for": "Kết quả tìm kiếm cho" } \ No newline at end of file diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index ead5e850..22bd4a80 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -292,5 +292,6 @@ "this_reply_was_deleted": "该回复已被其作者删除", "this_reply_was_removed": "该回复已被版主删除", "not_mod_of_any_board": "您不是任何板块的管理员。", - "filters_and_highlights": "筛选器和高亮" + "filters_and_highlights": "筛选器和高亮", + "search_results_for": "搜索结果" } \ No newline at end of file diff --git a/src/components/board-buttons/board-buttons.tsx b/src/components/board-buttons/board-buttons.tsx index 320a414c..61ce220c 100644 --- a/src/components/board-buttons/board-buttons.tsx +++ b/src/components/board-buttons/board-buttons.tsx @@ -246,7 +246,7 @@ export const MobileBoardButtons = () => { const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any }); const subplebbitAddress = params?.subplebbitAddress || accountComment?.subplebbitAddress; - const { filteredCount } = useCatalogFiltersStore(); + const { filteredCount, searchText } = useCatalogFiltersStore(); return (
@@ -269,11 +269,19 @@ export const MobileBoardButtons = () => { )} {!(isInAllView || isInSubscriptionsView) && } - {isInCatalogView && filteredCount > 0 && ( + {isInCatalogView && searchText ? ( {' '} - — {t('filtered_threads')}: {filteredCount} + — {t('search_results_for')}: {searchText} + ) : ( + isInCatalogView && + filteredCount > 0 && ( + + {' '} + — {t('filtered_threads')}: {filteredCount} + + ) )} {isInCatalogView && ( <> @@ -331,7 +339,7 @@ export const DesktopBoardButtons = () => { const isInPostView = isPostPageView(location.pathname, params); const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); - const { filteredCount } = useCatalogFiltersStore(); + const { filteredCount, searchText } = useCatalogFiltersStore(); return ( <> @@ -364,11 +372,19 @@ export const DesktopBoardButtons = () => { )} [] - {isInCatalogView && filteredCount > 0 && ( + {isInCatalogView && searchText ? ( {' '} - — {t('filtered_threads')}: {filteredCount} + — {t('search_results_for')}: {searchText} + ) : ( + isInCatalogView && + filteredCount > 0 && ( + + {' '} + — {t('filtered_threads')}: {filteredCount} + + ) )} {isInCatalogView && ( diff --git a/src/components/catalog-search/catalog-search.tsx b/src/components/catalog-search/catalog-search.tsx index 7c723dfe..1c25c08b 100644 --- a/src/components/catalog-search/catalog-search.tsx +++ b/src/components/catalog-search/catalog-search.tsx @@ -1,11 +1,43 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import styles from './catalog-search.module.css'; import useIsMobile from '../../hooks/use-is-mobile'; +import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; +import _ from 'lodash'; const CatalogSearch = () => { const { t } = useTranslation(); const [openSearch, setOpenSearch] = useState(false); + const [inputValue, setInputValue] = useState(''); + const { setSearchFilter, clearSearchFilter } = useCatalogFiltersStore(); + + // Create a debounced version of setSearchFilter + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedSetSearchFilter = useCallback( + _.debounce((text: string) => { + if (text.trim()) { + setSearchFilter(text); + } else { + clearSearchFilter(); + } + }, 300), + [setSearchFilter, clearSearchFilter], + ); + + const handleToggleSearch = useCallback(() => { + setOpenSearch((prev) => !prev); + + if (openSearch) { + setInputValue(''); + clearSearchFilter(); + } + }, [openSearch, clearSearchFilter]); + + const handleCloseSearch = useCallback(() => { + setOpenSearch(false); + setInputValue(''); + clearSearchFilter(); + }, [clearSearchFilter]); useEffect(() => { if (openSearch) { @@ -14,26 +46,42 @@ const CatalogSearch = () => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - setOpenSearch(false); + handleCloseSearch(); } }; window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; } - }, [openSearch]); + }, [openSearch, handleCloseSearch]); + + useEffect(() => { + debouncedSetSearchFilter(inputValue); + + return () => { + debouncedSetSearchFilter.cancel(); + }; + }, [inputValue, debouncedSetSearchFilter]); + + const handleSearchChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + }; const isMobile = useIsMobile(); return ( <> {!isMobile && '['} - setOpenSearch(!openSearch)}> + {t('search')} {!isMobile && ']'} {openSearch && (
- - setOpenSearch(false)}> + +
diff --git a/src/hooks/use-catalog-feed-rows.ts b/src/hooks/use-catalog-feed-rows.ts index bb995bbc..6ab2b373 100644 --- a/src/hooks/use-catalog-feed-rows.ts +++ b/src/hooks/use-catalog-feed-rows.ts @@ -7,6 +7,7 @@ import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils'; import { isAllView } from '../lib/utils/view-utils'; import { useMultisubMetadata } from './use-default-subplebbits'; import _ from 'lodash'; +import useCatalogFiltersStore from '../stores/use-catalog-filters-store'; const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolean, subplebbit: Subplebbit) => { const { t } = useTranslation(); @@ -19,6 +20,7 @@ const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolea const multisub = useMultisubMetadata(); const { accountComments } = useAccountComments(); + const { searchText } = useCatalogFiltersStore(); const feedWithFakePostsOnTop = useMemo(() => { if (!isFeedLoaded) { @@ -64,7 +66,7 @@ const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolea } // add subplebbit description and rules as fake posts at the top of the feed - if (description && description.length > 0) { + if (description && description.length > 0 && !searchText) { _feed.unshift({ isDescription: true, subplebbitAddress: address, @@ -79,7 +81,7 @@ const useCatalogFeedRows = (columnCount: number, feed: any, isFeedLoaded: boolea } // rules are shown in description thread if both are set - if (rules && rules.length > 0 && !description) { + if (rules && rules.length > 0 && !description && !searchText) { _feed.unshift({ isRules: true, subplebbitAddress: address, diff --git a/src/stores/use-catalog-filters-store.ts b/src/stores/use-catalog-filters-store.ts index 1b7d9728..edb50748 100644 --- a/src/stores/use-catalog-filters-store.ts +++ b/src/stores/use-catalog-filters-store.ts @@ -31,6 +31,9 @@ interface CatalogFiltersStore { currentSubplebbitAddress: string | null; setCurrentSubplebbitAddress: (address: string | null) => void; getFilteredCountForCurrentSubplebbit: () => number; + searchText: string; + setSearchFilter: (text: string) => void; + clearSearchFilter: () => void; } const useCatalogFiltersStore = create( @@ -48,6 +51,15 @@ const useCatalogFiltersStore = create( filteredCids: new Set(), currentSubplebbitAddress: null, setCurrentSubplebbitAddress: (address: string | null) => set({ currentSubplebbitAddress: address }), + searchText: '', + setSearchFilter: (text: string) => { + set({ searchText: text }); + get().updateFilter(); + }, + clearSearchFilter: () => { + set({ searchText: '' }); + get().updateFilter(); + }, setFilterItems: (items: FilterItem[]) => { const nonEmptyItems = items .filter((item) => item.text.trim() !== '') @@ -87,6 +99,17 @@ const useCatalogFiltersStore = create( set((state) => ({ filter: (comment: Comment) => { if (!comment?.cid) return true; + + if (state.searchText.trim() !== '') { + const searchPattern = state.searchText.toLowerCase(); + const title = comment?.title?.toLowerCase() || ''; + const content = comment?.content?.toLowerCase() || ''; + + if (!title.includes(searchPattern) && !content.includes(searchPattern)) { + return false; + } + } + const { filterItems } = state; let shouldHide = false; for (let i = 0; i < filterItems.length; i++) { diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx index 64b60d83..a0c5bb9a 100644 --- a/src/views/catalog/catalog.tsx +++ b/src/views/catalog/catalog.tsx @@ -92,19 +92,35 @@ const createImageFilter = (showTextOnlyThreads: boolean) => { const createCombinedFilter = ( showTextOnlyThreads: boolean, filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set; hide: boolean; top: boolean }[], + searchText: string, subplebbitAddress: string, onFilterMatch?: (filterIndex: number, cid: string, subplebbitAddress: string) => void, ) => { const imageFilter = createImageFilter(showTextOnlyThreads); const contentFilter = createContentFilter(filterItems, subplebbitAddress, onFilterMatch); + const searchFilter = { + filter: (comment: Comment) => { + if (!searchText.trim()) return true; + + const titleLower = comment?.title?.toLowerCase() || ''; + const contentLower = comment?.content?.toLowerCase() || ''; + const searchPattern = searchText.toLowerCase(); + + return titleLower.includes(searchPattern) || contentLower.includes(searchPattern); + }, + key: searchText ? `search-filter-${searchText}` : 'no-search-filter', + }; + return { filter: (comment: Comment) => { if (!imageFilter.filter(comment)) return false; + if (!contentFilter.filter(comment)) return false; + if (!searchFilter.filter(comment)) return false; - return contentFilter.filter(comment); + return true; }, - key: `${imageFilter.key}-${contentFilter.key}`, + key: `${imageFilter.key}-${contentFilter.key}-${searchFilter.key}`, }; }; @@ -116,7 +132,7 @@ const Catalog = () => { const isInAllView = isAllView(location.pathname); const defaultSubplebbits = useDefaultSubplebbits(); const { hideAdultBoards, hideGoreBoards } = useInterfaceSettingsStore(); - const { showTextOnlyThreads, filterItems } = useCatalogFiltersStore(); + const { showTextOnlyThreads, filterItems, searchText } = useCatalogFiltersStore(); const account = useAccount(); const subscriptions = account?.subscriptions; @@ -172,7 +188,7 @@ const Catalog = () => { subplebbitAddresses, sortType, postsPerPage: isInAllView || isInSubscriptionsView ? 10 : postsPerPage, - filter: createCombinedFilter(showTextOnlyThreads, filterItems, subplebbitAddress || 'all', handleFilterMatch), + filter: createCombinedFilter(showTextOnlyThreads, filterItems, searchText, subplebbitAddress || 'all', handleFilterMatch), }; if (isInAllView || isInSubscriptionsView) { @@ -189,6 +205,7 @@ const Catalog = () => { timeFilterSeconds, showTextOnlyThreads, filterItems, + searchText, subplebbitAddress, handleFilterMatch, ]); @@ -203,7 +220,9 @@ const Catalog = () => { () => accountComments.filter((comment) => { const { cid, deleted, link, linkHeight, linkWidth, postCid, removed, state, thumbnailUrl, timestamp } = comment || {}; - return ( + + // Basic filtering conditions + const basicConditions = !deleted && !removed && timestamp > Date.now() / 1000 - 60 * 60 && @@ -212,10 +231,20 @@ const Catalog = () => { (showTextOnlyThreads ? getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), comment?.link) : true) && cid === postCid && comment?.subplebbitAddress === subplebbitAddress && - !feed.some((post) => post.cid === cid) - ); + !feed.some((post) => post.cid === cid); + + // If search is active, also check search conditions + if (basicConditions && searchText.trim()) { + const titleLower = comment?.title?.toLowerCase() || ''; + const contentLower = comment?.content?.toLowerCase() || ''; + const searchPattern = searchText.toLowerCase(); + + return titleLower.includes(searchPattern) || contentLower.includes(searchPattern); + } + + return basicConditions; }), - [accountComments, subplebbitAddress, feed, showTextOnlyThreads], + [accountComments, subplebbitAddress, feed, showTextOnlyThreads, searchText], ); // show newest account comment at the top of the feed but after pinned posts @@ -247,14 +276,14 @@ const Catalog = () => { subplebbitAddresses, sortType, newerThan: 60 * 60 * 24 * 7, - filter: createCombinedFilter(showTextOnlyThreads, filterItems, subplebbitAddress || 'all', handleFilterMatch), + filter: createCombinedFilter(showTextOnlyThreads, filterItems, searchText, subplebbitAddress || 'all', handleFilterMatch), }); const { feed: monthlyFeed } = useFeed({ subplebbitAddresses, sortType, newerThan: 60 * 60 * 24 * 30, - filter: createCombinedFilter(showTextOnlyThreads, filterItems, subplebbitAddress || 'all', handleFilterMatch), + filter: createCombinedFilter(showTextOnlyThreads, filterItems, searchText, subplebbitAddress || 'all', handleFilterMatch), }); const [showMorePostsSuggestion, setShowMorePostsSuggestion] = useState(false); diff --git a/src/views/post/post.module.css b/src/views/post/post.module.css index 0bc3987c..19ca4464 100644 --- a/src/views/post/post.module.css +++ b/src/views/post/post.module.css @@ -562,7 +562,7 @@ width: 24px; max-height: 24px; position: absolute; - bottom: -5px; + bottom: -7px; } @media (max-width: 640px) { From 0faaa4982a632a1dc4a56ff5714ef2ff8cac06a2 Mon Sep 17 00:00:00 2001 From: "Tom (plebeius.eth)" Date: Thu, 6 Mar 2025 17:24:56 +0100 Subject: [PATCH 09/18] fix catalog filter count per subplebbit address --- .../catalog-filters/catalog-filters.tsx | 6 +- src/stores/use-catalog-filters-store.ts | 63 ++++++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/components/catalog-filters/catalog-filters.tsx b/src/components/catalog-filters/catalog-filters.tsx index 188ec343..d64e69d3 100644 --- a/src/components/catalog-filters/catalog-filters.tsx +++ b/src/components/catalog-filters/catalog-filters.tsx @@ -5,7 +5,7 @@ import styles from './catalog-filters.module.css'; const FiltersTable = ({ onSave }: { onSave: () => void }) => { const { t } = useTranslation(); - const { filterItems, saveAndApplyFilters } = useCatalogFiltersStore(); + const { filterItems, saveAndApplyFilters, currentSubplebbitAddress } = useCatalogFiltersStore(); const [localFilterItems, setLocalFilterItems] = useState( filterItems.map((item) => ({ @@ -127,7 +127,9 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { ×
{item.count > 0 && `x${item.count}`} + {currentSubplebbitAddress && (item.subplebbitCounts?.get(currentSubplebbitAddress) ?? 0) > 0 && `x${item.subplebbitCounts?.get(currentSubplebbitAddress)}`} +
- {currentSubplebbitAddress && (item.subplebbitCounts?.get(currentSubplebbitAddress) ?? 0) > 0 && `x${item.subplebbitCounts?.get(currentSubplebbitAddress)}`} + {currentSubplebbitAddress && item.subplebbitFilteredCids?.has(currentSubplebbitAddress) && `x${item.subplebbitCounts?.get(currentSubplebbitAddress) ?? 0}`}
- + updateLocalFilterItem(index, { ...item, hide: e.target.checked })} /> @@ -173,97 +178,26 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { ); }; -const FiltersProtip = () => { - return ( -
-

Patterns

-
    -
  • - Matching whole words: -
  • -
  • - feel — will match "feel" but not "feeling". This search is case-insensitive. -
  • -
-
    -
  • - AND operator: -
  • -
  • - feel girlfriend — will match "feel" AND "girlfriend" in any order. -
  • -
-
    -
  • - OR operator: -
  • -
  • - feel|girlfriend — will match "feel" OR "girlfriend". -
  • -
-
    -
  • - Mixing both operators: -
  • -
  • - girlfriend|boyfriend feel — matches "feel" AND "girlfriend", or "feel" AND "boyfriend". -
  • -
-
    -
  • - Exact match search: -
  • -
  • - "that feel when" — place double quotes around the pattern to search for an exact string. -
  • -
-
    -
  • - Wildcards: -
  • -
  • - feel* — matches expressions such as "feel", "feels", "feeling", "feeler", etc… -
  • -
  • - idolm*ster — this can match "idolmaster" or "idolm@ster", etc… -
  • -
-
    - It is also possible to filter by regular expression: -
  • - /^(?=.*detachable)(?=.*hats).*$/i — AND operator. -
  • -
  • - /^(?!.*touhou).*$/i — NOT operator. -
  • -
  • - {'/^>/'} — threads starting with a quote ({'">"'} character as an html entity). -
  • -
  • - /^$/ — threads with no text. -
  • -
-

Controls

-
    -
  • - On — enables or disables the filter. -
  • -
  • - Hide — hides matched threads. -
  • -
  • - Top — moves the filter to the top of the feed. -
  • -
-
- ); -}; - const FiltersModal = ({ closeModal }: { closeModal: () => void }) => { const { t } = useTranslation(); const [showHelp, setShowHelp] = useState(false); const openHelp = () => setShowHelp(true); const closeHelp = () => setShowHelp(false); + + useEffect(() => { + const onEscapeKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (showHelp) { + closeHelp(); + } else { + closeModal(); + } + } + }; + document.addEventListener('keydown', onEscapeKey); + return () => document.removeEventListener('keydown', onEscapeKey); + }, [closeModal, showHelp]); + return ( <>
@@ -287,16 +221,6 @@ const CatalogFilters = () => { setShowModal(false); }, []); - useEffect(() => { - const onEscapeKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - closeModal(); - } - }; - document.addEventListener('keydown', onEscapeKey); - return () => document.removeEventListener('keydown', onEscapeKey); - }, [closeModal]); - return ( <> setShowModal(true)}> diff --git a/src/components/catalog-filters/filters-protip/filters-protip.module.css b/src/components/catalog-filters/filters-protip/filters-protip.module.css new file mode 100644 index 00000000..96de3b06 --- /dev/null +++ b/src/components/catalog-filters/filters-protip/filters-protip.module.css @@ -0,0 +1,34 @@ +.filtersProtip { + text-transform: none; + text-align: left; +} + +.filtersProtip code { + padding: 1px 5px 1px 5px; + background-color: #EEE; + color: #000; +} + +.filtersProtip li { + list-style: none; + margin: 5px 0px; +} + +.filtersProtip ul { + margin-bottom: 10px; +} + +.filtersProtip h4 { + font-size: 15px; + margin: 10px 0px 5px 0px; + padding-top: 10px; +} + +.filtersProtip h4:first-child { + padding-top: 0px; +} + +.filtersProtip h4::before { + content: "»"; + margin-right: 3px; +} \ No newline at end of file diff --git a/src/components/catalog-filters/filters-protip/filters-protip.tsx b/src/components/catalog-filters/filters-protip/filters-protip.tsx new file mode 100644 index 00000000..9060a242 --- /dev/null +++ b/src/components/catalog-filters/filters-protip/filters-protip.tsx @@ -0,0 +1,89 @@ +import styles from './filters-protip.module.css'; + +const FiltersProtip = () => { + return ( +
+

Patterns

+
    +
  • + Matching whole words: +
  • +
  • + feel — will match "feel" but not "feeling". This search is case-insensitive. +
  • +
+
    +
  • + AND operator: +
  • +
  • + feel girlfriend — will match "feel" AND "girlfriend" in any order. +
  • +
+
    +
  • + OR operator: +
  • +
  • + feel|girlfriend — will match "feel" OR "girlfriend". +
  • +
+
    +
  • + Mixing both operators: +
  • +
  • + girlfriend|boyfriend feel — matches "feel" AND "girlfriend", or "feel" AND "boyfriend". +
  • +
+
    +
  • + Exact match search: +
  • +
  • + "that feel when" — place double quotes around the pattern to search for an exact string. +
  • +
+
    +
  • + Wildcards: +
  • +
  • + feel* — matches expressions such as "feel", "feels", "feeling", "feeler", etc… +
  • +
  • + idolm*ster — this can match "idolmaster" or "idolm@ster", etc… +
  • +
+
    + It is also possible to filter by regular expression: +
  • + /^(?=.*detachable)(?=.*hats).*$/i — AND operator. +
  • +
  • + /^(?!.*touhou).*$/i — NOT operator. +
  • +
  • + {'/^>/'} — threads starting with a quote ({'">"'} character as an html entity). +
  • +
  • + /^$/ — threads with no text. +
  • +
+

Controls

+
    +
  • + On — enables or disables the filter. +
  • +
  • + Hide — hides matched threads. +
  • +
  • + Top — moves the filter to the top of the feed. +
  • +
+
+ ); +}; + +export default FiltersProtip; diff --git a/src/components/catalog-filters/filters-protip/index.ts b/src/components/catalog-filters/filters-protip/index.ts new file mode 100644 index 00000000..6e661867 --- /dev/null +++ b/src/components/catalog-filters/filters-protip/index.ts @@ -0,0 +1 @@ +export { default } from './filters-protip'; diff --git a/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.module.css b/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.module.css new file mode 100644 index 00000000..38bdf5c1 --- /dev/null +++ b/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.module.css @@ -0,0 +1,110 @@ +.colorClickbox { + width: 16px; + height: 16px; + line-height: 16px; + font-size: 10px; + display: block; + text-align: center; + background-color: #fff; + border: var(--reply-modal-field-input-border, revert); + text-decoration: none; + color: #000; + position: relative; + cursor: pointer; +} + +.colorPickerModal { + padding: 4px; + z-index: 1000; + font-size: 14px; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.25); + background-color: var(--settings-modal-background-color); +} + +.colorPickerModal table { + border-spacing: 1px; + margin-left: auto; + margin-right: auto; +} + +.colorPickerModal table td { + padding: 2px; + text-align: center; + font-size: 10pt; + margin: 0; +} + +.colorOption { + box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); + margin: auto; + width: 16px; + height: 16px; + line-height: 16px; + font-size: 10px; + display: block; + text-align: center; + border: 1px solid #aaa; + text-decoration: none; + color: #000; + white-space: nowrap; + user-select: none; + cursor: pointer; +} + +.middleTxt input[type="text"] { + width: 45px; + margin: 0 2px; + margin-right: 5px; + padding: 1px; + outline: none; + border: var(--reply-modal-field-input-border, revert); + font-size: 11px; + background-color: default; + vertical-align: top; +} + +.middleTxt input[type="text"]:focus { + border: var(--reply-modal-field-input-border-focus, revert); +} + +@media (max-width: 640px) { + .middleTxt input[type="text"] { + font-size: 16px !important; + } +} + +.middleTxt .colorClickbox { + width: 16px; + height: 16px; + line-height: 16px; + font-size: 10px; + text-align: center; + border: 1px solid #aaa; + text-decoration: none; + color: #000; + box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); + margin: auto; + cursor: pointer; + float: right; + background-color: transparent !important; +} + +.colorPickerModal tfoot .button { + color: var(--button-desktop-text-color); + cursor: pointer; + margin: 0 2px; +} + +.colorPickerModal tfoot .button:hover { + color: var(--button-desktop-text-color-hover); +} + +.colorPickerModal tfoot .button::before{ + content: '['; + color: var(--body-font-color); +} + +.colorPickerModal tfoot .button::after{ + content: ']'; + color: var(--body-font-color); +} diff --git a/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.tsx b/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.tsx new file mode 100644 index 00000000..10f11f05 --- /dev/null +++ b/src/components/catalog-filters/highlight-color-picker/highlight-color-picker.tsx @@ -0,0 +1,145 @@ +import { useState, useRef } from 'react'; +import styles from './highlight-color-picker.module.css'; +import { autoUpdate, FloatingFocusManager, FloatingPortal, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react'; + +interface ColorPickerProps { + item: any; + index: number; + updateLocalFilterItem: (index: number, item: any) => void; + localFilterItems: any[]; +} + +const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterItems }: ColorPickerProps) => { + const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); + const [customColor, setCustomColor] = useState(''); + + const inputRef = useRef(null); + + const { refs, floatingStyles, context } = useFloating({ + placement: 'left-start', + open: isColorPickerOpen, + onOpenChange: setIsColorPickerOpen, + middleware: [offset(10), shift()], + whileElementsMounted: autoUpdate, + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]); + + const headingId = useId(); + + const colorOptions = ['#E0B0FF', '#F2F3F4', '#7DF9FF', '#FFFF00', '#FBCEB1', '#FFBF00', '#ADFF2F', '#0047AB', '#00A550', '#007FFF', '#AF0A0F', '#B5BD68']; + + const colorRows = [colorOptions.slice(0, 4), colorOptions.slice(4, 8), colorOptions.slice(8, 12)]; + + const setItemColor = (color: string) => { + updateLocalFilterItem(index, { ...localFilterItems[index], color }); + setIsColorPickerOpen(false); + }; + + const handleCustomColorChange = (e: React.ChangeEvent) => { + const newColor = e.target.value; + setCustomColor(newColor); + }; + + const applyCustomColor = () => { + if (customColor) { + setItemColor(customColor); + } + }; + + const clearColor = () => { + setItemColor(''); + }; + + return ( + <> + + {!item.color && '∕'} + + + {isColorPickerOpen && ( + +
{ + e.preventDefault(); + e.stopPropagation(); + setIsColorPickerOpen(false); + }} + /> + +
e.stopPropagation()} + > + + + {colorRows.map((row, rowIndex) => ( + + {row.map((color, colorIndex) => ( + + ))} + + ))} + + + + + + + + + + + + +
+ setItemColor(color)} /> +
Custom
+ + +
+ setIsColorPickerOpen(false)}> + Close + + + Clear + +
+
+
+ + )} + + ); +}; + +export default HighlightColorPicker; diff --git a/src/components/catalog-filters/highlight-color-picker/index.ts b/src/components/catalog-filters/highlight-color-picker/index.ts new file mode 100644 index 00000000..63571df7 --- /dev/null +++ b/src/components/catalog-filters/highlight-color-picker/index.ts @@ -0,0 +1 @@ +export { default } from './highlight-color-picker'; diff --git a/src/components/catalog-row/catalog-row.module.css b/src/components/catalog-row/catalog-row.module.css index df3843f8..748a5262 100644 --- a/src/components/catalog-row/catalog-row.module.css +++ b/src/components/catalog-row/catalog-row.module.css @@ -13,6 +13,8 @@ padding: 10px 0 3px 0; vertical-align: top; display: inline-block; + box-sizing: border-box; + margin: 2px; } .postContent { @@ -69,14 +71,14 @@ background-color: var(--media-thumbnail-background-color); width: var(--width); height: var(--height); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.25); display: inline-flex; justify-content: center; align-items: center; - clip-path: inset(-5px); /* direct media could change its dimensions after being published, so we need to clip the overflow. We can't use overflow: hidden because it will hide the box-shadow */ + overflow: hidden; } .post img, .post video, .post audio { - box-shadow: 0 0 5px rgba(0, 0, 0, 0.25); max-width: var(--maxWidth); max-height: var(--maxHeight); } diff --git a/src/components/catalog-row/catalog-row.tsx b/src/components/catalog-row/catalog-row.tsx index dcf6d556..8ffc4f63 100644 --- a/src/components/catalog-row/catalog-row.tsx +++ b/src/components/catalog-row/catalog-row.tsx @@ -2,34 +2,36 @@ import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Link, useLocation, useParams } from 'react-router-dom'; -import { Comment } from '@plebbit/plebbit-react-hooks'; import { useFloating, offset, size, autoUpdate, Placement } from '@floating-ui/react'; +import { Comment } from '@plebbit/plebbit-react-hooks'; +import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js'; +import { shouldShowSnow } from '../../lib/snow'; import { getHasThumbnail } from '../../lib/utils/media-utils'; import { getFormattedTimeAgo } from '../../lib/utils/time-utils'; import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils'; -import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js'; +import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; import useCatalogStyleStore from '../../stores/use-catalog-style-store'; import useEditCommentPrivileges from '../../hooks/use-author-privileges'; +import { useCommentMediaInfo } from '../../hooks/use-comment-media-info'; import useCountLinksInReplies from '../../hooks/use-count-links-in-replies'; import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; import useHide from '../../hooks/use-hide'; import useWindowWidth from '../../hooks/use-window-width'; +import useReplies from '../../hooks/use-replies'; +import { ContentPreview } from '../../views/home/popular-threads-box'; import PostMenuDesktop from '../post-desktop/post-menu-desktop'; import styles from './catalog-row.module.css'; import _ from 'lodash'; -import { ContentPreview } from '../../views/home/popular-threads-box'; -import { useCommentMediaInfo } from '../../hooks/use-comment-media-info'; -import { shouldShowSnow } from '../../lib/snow'; -import useReplies from '../../hooks/use-replies'; interface CatalogPostMediaProps { + cid: string; commentMediaInfo: any; isOutOfFeed?: boolean; linkWidth?: number; linkHeight?: number; } -export const CatalogPostMedia = ({ commentMediaInfo, isOutOfFeed, linkWidth, linkHeight }: CatalogPostMediaProps) => { +export const CatalogPostMedia = ({ cid, commentMediaInfo, isOutOfFeed, linkWidth, linkHeight }: CatalogPostMediaProps) => { const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {}; const iframeThumbnail = patternThumbnailUrl || thumbnail; const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined); @@ -89,8 +91,16 @@ export const CatalogPostMedia = ({ commentMediaInfo, isOutOfFeed, linkWidth, lin thumbnailComponent =