diff options
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 13 | ||||
| -rw-r--r-- | 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<string, Card[]>(); @@ -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<HTMLInputElement>) => { + const value = e.target.value; + setSearchInput(value); + debouncedSetQuery(value); + }, + [debouncedSetQuery], + ); + + const handleClearSearch = useCallback(() => { + setSearchInput(""); + setSearchQuery(""); + debouncedSetQuery.cancel(); + }, [debouncedSetQuery]); return ( <div className="animate-fade-in"> @@ -390,7 +446,7 @@ function CardsContent({ </ErrorBoundary> {/* Cards Section */} - <div className="flex items-center justify-between mb-6"> + <div className="flex items-center justify-between mb-4"> <h2 className="font-display text-xl font-medium text-slate"> Cards <span className="text-muted font-normal">({cards.length})</span> </h2> @@ -422,9 +478,39 @@ function CardsContent({ </div> </div> + {/* Search */} + <div className="relative mb-6"> + <FontAwesomeIcon + icon={faMagnifyingGlass} + className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted pointer-events-none" + aria-hidden="true" + /> + <input + type="text" + value={searchInput} + onChange={handleSearchChange} + placeholder="Search cards..." + className="w-full pl-10 pr-9 py-2.5 bg-white border border-border/50 rounded-lg text-sm text-slate placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors" + /> + {searchInput && ( + <button + type="button" + onClick={handleClearSearch} + className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-slate transition-colors" + > + <FontAwesomeIcon + icon={faXmark} + className="w-4 h-4" + aria-hidden="true" + /> + </button> + )} + </div> + {/* Card List */} <CardList deckId={deckId} + searchQuery={searchQuery} onEditNote={onEditNote} onDeleteNote={onDeleteNote} onCreateNote={onCreateNote} |
