From 081498168fe25b377f4675637c57a08e4e399f95 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 31 Jan 2026 10:49:39 +0900 Subject: feat(cards): add search filtering to deck cards page Add a debounced (500ms) search input using use-debounce that filters note groups by matching card front/back content (case-insensitive). Pagination resets to page 1 when the search query changes. Co-Authored-By: Claude Opus 4.5 --- package.json | 1 + pnpm-lock.yaml | 13 ++++++ src/client/pages/DeckCardsPage.tsx | 94 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 135c898..b8a8f56 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "ts-fsrs": "^5.2.3", + "use-debounce": "^10.1.0", "uuid": "^13.0.0", "wouter": "^3.9.0", "zod": "^4.3.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33674b7..8b39ab9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: ts-fsrs: specifier: ^5.2.3 version: 5.2.3 + use-debounce: + specifier: ^10.1.0 + version: 10.1.0(react@19.2.3) uuid: specifier: ^13.0.0 version: 13.0.0 @@ -3171,6 +3174,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + use-debounce@10.1.0: + resolution: {integrity: sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + react: '*' + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -6352,6 +6361,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + use-debounce@10.1.0(react@19.2.3): + dependencies: + react: 19.2.3 + use-sync-external-store@1.6.0(react@19.2.3): dependencies: react: 19.2.3 diff --git a/src/client/pages/DeckCardsPage.tsx b/src/client/pages/DeckCardsPage.tsx index 8ac5c91..f7fd3b7 100644 --- a/src/client/pages/DeckCardsPage.tsx +++ b/src/client/pages/DeckCardsPage.tsx @@ -4,13 +4,24 @@ import { faFile, faFileImport, faLayerGroup, + faMagnifyingGlass, faPen, faPlus, faTrash, + faXmark, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useAtomValue, useSetAtom } from "jotai"; -import { Suspense, useCallback, useMemo, useState, useTransition } from "react"; +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useTransition, +} from "react"; +import { useDebouncedCallback } from "use-debounce"; import { Link, useParams } from "wouter"; import { type Card, cardsByDeckAtomFamily, deckByIdAtomFamily } from "../atoms"; import { CreateNoteModal } from "../components/CreateNoteModal"; @@ -245,11 +256,13 @@ function Pagination({ function CardList({ deckId, + searchQuery, onEditNote, onDeleteNote, onCreateNote, }: { deckId: string; + searchQuery: string; onEditNote: (noteId: string) => void; onDeleteNote: (noteId: string) => void; onCreateNote: () => void; @@ -257,7 +270,7 @@ function CardList({ const cards = useAtomValue(cardsByDeckAtomFamily(deckId)); const [currentPage, setCurrentPage] = useState(0); - // Group cards by note for display + // Group cards by note for display, applying search filter const displayItems = useMemo((): CardDisplayItem[] => { const noteGroups = new Map(); @@ -283,6 +296,7 @@ function CardList({ }, ); + const query = searchQuery.toLowerCase(); const items: CardDisplayItem[] = []; for (const [noteId, noteCards] of sortedNoteGroups) { // Sort cards within group: normal first, then reversed @@ -290,11 +304,33 @@ function CardList({ if (a.isReversed === b.isReversed) return 0; return a.isReversed ? 1 : -1; }); + + // Filter: if query is set, only include note groups where any card matches + if ( + query && + !noteCards.some( + (c) => + c.front.toLowerCase().includes(query) || + c.back.toLowerCase().includes(query), + ) + ) { + continue; + } + items.push({ type: "note", noteId, cards: noteCards }); } return items; - }, [cards]); + }, [cards, searchQuery]); + + // Reset to first page when search query changes + const prevSearchQuery = useRef(searchQuery); + useEffect(() => { + if (prevSearchQuery.current !== searchQuery) { + prevSearchQuery.current = searchQuery; + setCurrentPage(0); + } + }, [searchQuery]); const pages = useMemo(() => paginateNoteGroups(displayItems), [displayItems]); const totalPages = pages.length; @@ -379,6 +415,26 @@ function CardsContent({ onDeleteNote: (noteId: string) => void; }) { const cards = useAtomValue(cardsByDeckAtomFamily(deckId)); + const [searchInput, setSearchInput] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const debouncedSetQuery = useDebouncedCallback((value: string) => { + setSearchQuery(value); + }, 500); + + const handleSearchChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setSearchInput(value); + debouncedSetQuery(value); + }, + [debouncedSetQuery], + ); + + const handleClearSearch = useCallback(() => { + setSearchInput(""); + setSearchQuery(""); + debouncedSetQuery.cancel(); + }, [debouncedSetQuery]); return (
@@ -390,7 +446,7 @@ function CardsContent({ {/* Cards Section */} -
+

Cards ({cards.length})

@@ -422,9 +478,39 @@ function CardsContent({
+ {/* Search */} +
+
+ {/* Card List */}