import { faChevronLeft, faChevronRight, faFile, faFileImport, faLayerGroup, faMagnifyingGlass, faPen, faPlus, faTrash, faXmark, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useAtomValue } from "jotai"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useDebouncedCallback } from "use-debounce"; import { Link, useParams } from "wouter"; import { type Card, cardsByDeckAtomFamily, deckByIdAtomFamily } from "../atoms"; import { CreateNoteModal } from "../components/CreateNoteModal"; import { DeleteCardModal } from "../components/DeleteCardModal"; import { DeleteNoteModal } from "../components/DeleteNoteModal"; import { EditCardModal } from "../components/EditCardModal"; import { EditNoteModal } from "../components/EditNoteModal"; import { ErrorBoundary } from "../components/ErrorBoundary"; import { ImportNotesModal } from "../components/ImportNotesModal"; import { queryClient } from "../queryClient"; /** Combined type for display: note group */ type CardDisplayItem = { type: "note"; noteId: string; cards: Card[] }; const CARDS_PER_PAGE = 50; const CardStateLabels: Record = { 0: "New", 1: "Learning", 2: "Review", 3: "Relearning", }; const CardStateColors: Record = { 0: "bg-info/10 text-info", 1: "bg-warning/10 text-warning", 2: "bg-success/10 text-success", 3: "bg-error/10 text-error", }; /** Component for displaying a group of cards from the same note */ function NoteGroupCard({ noteId, cards, index, onEditNote, onDeleteNote, }: { noteId: string; cards: Card[]; index: number; onEditNote: () => void; onDeleteNote: () => void; }) { // Use the first card's front/back as preview (normal card takes precedence) const previewCard = cards.find((c) => !c.isReversed) ?? cards[0]; if (!previewCard) return null; return (
{/* Note Header */}
{/* Note Content Preview */}
Front

{previewCard.front}

Back

{previewCard.back}

{/* Cards within this note */}
{cards.map((card) => (
{CardStateLabels[card.state] || "Unknown"} {card.isReversed ? ( Reversed ) : ( Normal )} {card.reps} reviews {card.lapses > 0 && ( {card.lapses} lapses )}
))}
); } function DeckHeader({ deckId }: { deckId: string }) { const { data: deck } = useAtomValue(deckByIdAtomFamily(deckId)); return (

{deck.name}

{deck.description &&

{deck.description}

}
); } /** Paginate note groups so each page contains at most CARDS_PER_PAGE cards */ function paginateNoteGroups(items: CardDisplayItem[]): CardDisplayItem[][] { const pages: CardDisplayItem[][] = []; let currentPage: CardDisplayItem[] = []; let currentCount = 0; for (const item of items) { if (currentCount > 0 && currentCount + item.cards.length > CARDS_PER_PAGE) { pages.push(currentPage); currentPage = []; currentCount = 0; } currentPage.push(item); currentCount += item.cards.length; } if (currentPage.length > 0) { pages.push(currentPage); } return pages; } function Pagination({ currentPage, totalPages, onPageChange, }: { currentPage: number; totalPages: number; onPageChange: (page: number) => void; }) { if (totalPages <= 1) return null; return (
{currentPage + 1} / {totalPages}
); } function CardList({ deckId, searchQuery, onEditNote, onDeleteNote, onCreateNote, }: { deckId: string; searchQuery: string; onEditNote: (noteId: string) => void; onDeleteNote: (noteId: string) => void; onCreateNote: () => void; }) { const { data: cards } = useAtomValue(cardsByDeckAtomFamily(deckId)); const [currentPage, setCurrentPage] = useState(0); // Group cards by note for display, applying search filter const displayItems = useMemo((): CardDisplayItem[] => { const noteGroups = new Map(); for (const card of cards) { const existing = noteGroups.get(card.noteId); if (existing) { existing.push(card); } else { noteGroups.set(card.noteId, [card]); } } // Sort note groups by earliest card creation (newest first) const sortedNoteGroups = Array.from(noteGroups.entries()).sort( ([, cardsA], [, cardsB]) => { const minA = Math.min( ...cardsA.map((c) => new Date(c.createdAt).getTime()), ); const minB = Math.min( ...cardsB.map((c) => new Date(c.createdAt).getTime()), ); return minB - minA; // Newest first }, ); const query = searchQuery.toLowerCase(); const items: CardDisplayItem[] = []; for (const [noteId, noteCards] of sortedNoteGroups) { // Sort cards within group: normal first, then reversed noteCards.sort((a, b) => { 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, 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; // Clamp current page when data changes (e.g. after deletion) const safePage = totalPages > 0 ? Math.min(currentPage, totalPages - 1) : 0; if (safePage !== currentPage) { setCurrentPage(safePage); } const handlePageChange = useCallback((page: number) => { setCurrentPage(page); window.scrollTo({ top: 0, behavior: "smooth" }); }, []); if (cards.length === 0) { return (

No cards yet

Add notes to start studying

); } const pageItems = pages[safePage] ?? []; return (
{pageItems.map((item, index) => ( onEditNote(item.noteId)} onDeleteNote={() => onDeleteNote(item.noteId)} /> ))}
); } function CardsContent({ deckId, onCreateNote, onImportNotes, onEditNote, onDeleteNote, }: { deckId: string; onCreateNote: () => void; onImportNotes: () => void; onEditNote: (noteId: string) => void; onDeleteNote: (noteId: string) => void; }) { const { data: 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 (
{/* Deck Header */}
} > {/* Cards Section */}

Cards ({cards.length})

{/* Search */}
{/* Card List */}
); } export function DeckCardsPage() { const { deckId } = useParams<{ deckId: string }>(); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [editingCard, setEditingCard] = useState(null); const [editingNoteId, setEditingNoteId] = useState(null); const [deletingCard, setDeletingCard] = useState(null); const [deletingNoteId, setDeletingNoteId] = useState(null); const handleCardMutation = () => { queryClient.invalidateQueries({ queryKey: ["decks", deckId, "cards"] }); }; if (!deckId) { return (

Invalid deck ID

Back to decks
); } return (
{/* Header */}
{/* Main Content */}
{[0, 1, 2].map((i) => (
))}
} > setIsCreateModalOpen(true)} onImportNotes={() => setIsImportModalOpen(true)} onEditNote={setEditingNoteId} onDeleteNote={setDeletingNoteId} />
{/* Modals */} setIsCreateModalOpen(false)} onNoteCreated={handleCardMutation} /> setIsImportModalOpen(false)} onImportComplete={handleCardMutation} /> setEditingCard(null)} onCardUpdated={handleCardMutation} /> setEditingNoteId(null)} onNoteUpdated={handleCardMutation} /> setDeletingCard(null)} onCardDeleted={handleCardMutation} /> setDeletingNoteId(null)} onNoteDeleted={handleCardMutation} />
); }