From 8b212f3030ec30ed68410e609ed55fd7f0b06ea0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 01:16:15 +0000 Subject: feat(deck): separate card list from deck detail page Separate the card list view from the deck learning page to prevent users from seeing cards they are about to study. The deck detail page now shows only study statistics with a "Study Now" button and a "View Cards" link. - Add new DeckCardsPage component at /decks/:deckId/cards for managing cards - Simplify DeckDetailPage to show deck stats and navigation buttons - Update routing in App.tsx with proper route ordering - Add comprehensive tests for both pages --- src/client/pages/DeckCardsPage.tsx | 457 +++++++++++++++++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 src/client/pages/DeckCardsPage.tsx (limited to 'src/client/pages/DeckCardsPage.tsx') diff --git a/src/client/pages/DeckCardsPage.tsx b/src/client/pages/DeckCardsPage.tsx new file mode 100644 index 0000000..416760a --- /dev/null +++ b/src/client/pages/DeckCardsPage.tsx @@ -0,0 +1,457 @@ +import { + faChevronLeft, + faFile, + faFileImport, + faLayerGroup, + faPen, + faPlus, + faTrash, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useAtomValue, useSetAtom } from "jotai"; +import { Suspense, useMemo, useState, useTransition } from "react"; +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 { LoadingSpinner } from "../components/LoadingSpinner"; + +/** Combined type for display: note group */ +type CardDisplayItem = { type: "note"; noteId: string; cards: Card[] }; + +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 deck = useAtomValue(deckByIdAtomFamily(deckId)); + + return ( +
+

+ {deck.name} +

+ {deck.description &&

{deck.description}

} +
+ ); +} + +function CardList({ + deckId, + onEditNote, + onDeleteNote, + onCreateNote, +}: { + deckId: string; + onEditNote: (noteId: string) => void; + onDeleteNote: (noteId: string) => void; + onCreateNote: () => void; +}) { + const cards = useAtomValue(cardsByDeckAtomFamily(deckId)); + + // Group cards by note for display + 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 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; + }); + items.push({ type: "note", noteId, cards: noteCards }); + } + + return items; + }, [cards]); + + if (cards.length === 0) { + return ( +
+
+
+

+ No cards yet +

+

Add notes to start studying

+ +
+ ); + } + + return ( +
+ {displayItems.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 cards = useAtomValue(cardsByDeckAtomFamily(deckId)); + + return ( +
+ {/* Deck Header */} + + }> + + + + + {/* Cards Section */} +
+

+ Cards ({cards.length}) +

+
+ + +
+
+ + {/* Card List */} + +
+ ); +} + +export function DeckCardsPage() { + const { deckId } = useParams<{ deckId: string }>(); + const [, startTransition] = useTransition(); + + const reloadCards = useSetAtom(cardsByDeckAtomFamily(deckId || "")); + + 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 = () => { + startTransition(() => { + reloadCards(); + }); + }; + + if (!deckId) { + return ( +
+
+

Invalid deck ID

+ + Back to decks + +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+
+ + {/* Main Content */} +
+ + }> + 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} + /> +
+ ); +} -- cgit v1.3-1-g0d28