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 --- src/client/pages/DeckCardsPage.tsx | 94 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 4 deletions(-) (limited to 'src/client/pages') 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 */}