diff options
| author | nsfisis <54318333+nsfisis@users.noreply.github.com> | 2026-01-20 19:55:24 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-20 19:55:24 +0900 |
| commit | 86a18da3ff9c5d9edcc7fc17c19e5e36eb16aef6 (patch) | |
| tree | 487d93edd18f6544f576bff57f86ad30bf640080 /src/client/pages/DeckDetailPage.tsx | |
| parent | 188c49e6ae0dfa0af052a001bc40c26d448b1583 (diff) | |
| parent | 8b212f3030ec30ed68410e609ed55fd7f0b06ea0 (diff) | |
| download | kioku-86a18da3ff9c5d9edcc7fc17c19e5e36eb16aef6.tar.gz kioku-86a18da3ff9c5d9edcc7fc17c19e5e36eb16aef6.tar.zst kioku-86a18da3ff9c5d9edcc7fc17c19e5e36eb16aef6.zip | |
Merge pull request #10 from nsfisis/claude/separate-deck-learning-view-jb2rV
Separate deck detail and card learning pages
Diffstat (limited to 'src/client/pages/DeckDetailPage.tsx')
| -rw-r--r-- | src/client/pages/DeckDetailPage.tsx | 416 |
1 files changed, 46 insertions, 370 deletions
diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx index 1376fab..d39f063 100644 --- a/src/client/pages/DeckDetailPage.tsx +++ b/src/client/pages/DeckDetailPage.tsx @@ -1,164 +1,16 @@ import { faChevronLeft, faCirclePlay, - 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 { useAtomValue } from "jotai"; +import { Suspense } 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 { cardsByDeckAtomFamily, deckByIdAtomFamily } from "../atoms"; 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<number, string> = { - 0: "New", - 1: "Learning", - 2: "Review", - 3: "Relearning", -}; - -const CardStateColors: Record<number, string> = { - 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 ( - <div - data-testid="note-group" - data-note-id={noteId} - className="bg-white rounded-xl border border-border/50 shadow-card hover:shadow-md transition-all duration-200 overflow-hidden" - style={{ animationDelay: `${index * 30}ms` }} - > - {/* Note Header */} - <div className="flex items-center justify-between px-5 py-3 border-b border-border/30 bg-ivory/30"> - <div className="flex items-center gap-2"> - <FontAwesomeIcon - icon={faLayerGroup} - className="w-4 h-4 text-muted" - aria-hidden="true" - /> - <span className="text-sm font-medium text-slate"> - Note ({cards.length} card{cards.length !== 1 ? "s" : ""}) - </span> - </div> - <div className="flex items-center gap-1"> - <button - type="button" - onClick={onEditNote} - className="p-2 text-muted hover:text-slate hover:bg-white rounded-lg transition-colors" - title="Edit note" - > - <FontAwesomeIcon - icon={faPen} - className="w-4 h-4" - aria-hidden="true" - /> - </button> - <button - type="button" - onClick={onDeleteNote} - className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors" - title="Delete note" - > - <FontAwesomeIcon - icon={faTrash} - className="w-4 h-4" - aria-hidden="true" - /> - </button> - </div> - </div> - - {/* Note Content Preview */} - <div className="p-5"> - <div className="grid grid-cols-2 gap-4 mb-4"> - <div> - <span className="text-xs font-medium text-muted uppercase tracking-wide"> - Front - </span> - <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words"> - {previewCard.front} - </p> - </div> - <div> - <span className="text-xs font-medium text-muted uppercase tracking-wide"> - Back - </span> - <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words"> - {previewCard.back} - </p> - </div> - </div> - - {/* Cards within this note */} - <div className="space-y-2"> - {cards.map((card) => ( - <div - key={card.id} - data-testid="note-card" - className="flex items-center gap-3 text-xs p-2 bg-ivory/50 rounded-lg" - > - <span - className={`px-2 py-0.5 rounded-full font-medium ${CardStateColors[card.state] || "bg-muted/10 text-muted"}`} - > - {CardStateLabels[card.state] || "Unknown"} - </span> - {card.isReversed ? ( - <span className="px-2 py-0.5 rounded-full font-medium bg-purple-100 text-purple-700"> - Reversed - </span> - ) : ( - <span className="px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-700"> - Normal - </span> - )} - <span className="text-muted">{card.reps} reviews</span> - {card.lapses > 0 && ( - <span className="text-muted">{card.lapses} lapses</span> - )} - </div> - ))} - </div> - </div> - </div> - ); -} - function DeckHeader({ deckId }: { deckId: string }) { const deck = useAtomValue(deckByIdAtomFamily(deckId)); @@ -172,119 +24,32 @@ function DeckHeader({ deckId }: { deckId: string }) { ); } -function CardList({ - deckId, - onEditNote, - onDeleteNote, - onCreateNote, -}: { - deckId: string; - onEditNote: (noteId: string) => void; - onDeleteNote: (noteId: string) => void; - onCreateNote: () => void; -}) { +function DeckStats({ deckId }: { deckId: string }) { const cards = useAtomValue(cardsByDeckAtomFamily(deckId)); - // Group cards by note for display - const displayItems = useMemo((): CardDisplayItem[] => { - const noteGroups = new Map<string, Card[]>(); - - 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]); + // Count cards due today + const now = new Date(); + const dueCards = cards.filter((card) => new Date(card.due) <= now); - if (cards.length === 0) { - return ( - <div className="text-center py-12 bg-white rounded-xl border border-border/50"> - <div className="w-14 h-14 mx-auto mb-4 bg-ivory rounded-xl flex items-center justify-center"> - <FontAwesomeIcon - icon={faFile} - className="w-7 h-7 text-muted" - aria-hidden="true" - /> + return ( + <div className="bg-white rounded-xl border border-border/50 p-6 mb-6"> + <div className="grid grid-cols-2 gap-6"> + <div> + <p className="text-sm text-muted mb-1">Total Cards</p> + <p className="text-2xl font-semibold text-ink">{cards.length}</p> + </div> + <div> + <p className="text-sm text-muted mb-1">Due Today</p> + <p className="text-2xl font-semibold text-primary"> + {dueCards.length} + </p> </div> - <h3 className="font-display text-lg font-medium text-slate mb-2"> - No cards yet - </h3> - <p className="text-muted text-sm mb-4">Add notes to start studying</p> - <button - type="button" - onClick={onCreateNote} - className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200" - > - <FontAwesomeIcon - icon={faPlus} - className="w-5 h-5" - aria-hidden="true" - /> - Add Your First Note - </button> </div> - ); - } - - return ( - <div className="space-y-4"> - {displayItems.map((item, index) => ( - <NoteGroupCard - key={item.noteId} - noteId={item.noteId} - cards={item.cards} - index={index} - onEditNote={() => onEditNote(item.noteId)} - onDeleteNote={() => onDeleteNote(item.noteId)} - /> - ))} </div> ); } -function DeckContent({ - deckId, - onCreateNote, - onImportNotes, - onEditNote, - onDeleteNote, -}: { - deckId: string; - onCreateNote: () => void; - onImportNotes: () => void; - onEditNote: (noteId: string) => void; - onDeleteNote: (noteId: string) => void; -}) { - const cards = useAtomValue(cardsByDeckAtomFamily(deckId)); - +function DeckContent({ deckId }: { deckId: string }) { return ( <div className="animate-fade-in"> {/* Deck Header */} @@ -294,83 +59,47 @@ function DeckContent({ </Suspense> </ErrorBoundary> - {/* Study Button */} - <div className="mb-8"> + {/* Deck Stats */} + <ErrorBoundary> + <Suspense fallback={<LoadingSpinner />}> + <DeckStats deckId={deckId} /> + </Suspense> + </ErrorBoundary> + + {/* Action Buttons */} + <div className="space-y-4"> + {/* Study Button */} <Link href={`/decks/${deckId}/study`} - className="inline-flex items-center gap-2 bg-success hover:bg-success/90 text-white font-medium py-3 px-6 rounded-xl transition-all duration-200 active:scale-[0.98] shadow-sm hover:shadow-md" + className="flex items-center justify-center gap-3 w-full bg-success hover:bg-success/90 text-white font-medium py-4 px-6 rounded-xl transition-all duration-200 active:scale-[0.98] shadow-sm hover:shadow-md" > <FontAwesomeIcon icon={faCirclePlay} - className="w-5 h-5" + className="w-6 h-6" aria-hidden="true" /> - Study Now + <span className="text-lg">Study Now</span> </Link> - </div> - {/* Cards Section */} - <div className="flex items-center justify-between mb-6"> - <h2 className="font-display text-xl font-medium text-slate"> - Cards <span className="text-muted font-normal">({cards.length})</span> - </h2> - <div className="flex items-center gap-2"> - <button - type="button" - onClick={onImportNotes} - className="inline-flex items-center gap-2 border border-border hover:bg-ivory text-slate font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]" - > - <FontAwesomeIcon - icon={faFileImport} - className="w-5 h-5" - aria-hidden="true" - /> - Import CSV - </button> - <button - type="button" - onClick={onCreateNote} - className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]" - > - <FontAwesomeIcon - icon={faPlus} - className="w-5 h-5" - aria-hidden="true" - /> - Add Note - </button> - </div> + {/* View Cards Link */} + <Link + href={`/decks/${deckId}/cards`} + className="flex items-center justify-center gap-3 w-full border border-border hover:bg-ivory text-slate font-medium py-4 px-6 rounded-xl transition-all duration-200 active:scale-[0.98]" + > + <FontAwesomeIcon + icon={faLayerGroup} + className="w-5 h-5" + aria-hidden="true" + /> + <span className="text-lg">View Cards</span> + </Link> </div> - - {/* Card List */} - <CardList - deckId={deckId} - onEditNote={onEditNote} - onDeleteNote={onDeleteNote} - onCreateNote={onCreateNote} - /> </div> ); } export function DeckDetailPage() { 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<Card | null>(null); - const [editingNoteId, setEditingNoteId] = useState<string | null>(null); - const [deletingCard, setDeletingCard] = useState<Card | null>(null); - const [deletingNoteId, setDeletingNoteId] = useState<string | null>(null); - - const handleCardMutation = () => { - startTransition(() => { - reloadCards(); - }); - }; if (!deckId) { return ( @@ -411,63 +140,10 @@ export function DeckDetailPage() { <main className="max-w-4xl mx-auto px-4 py-8"> <ErrorBoundary> <Suspense fallback={<LoadingSpinner />}> - <DeckContent - deckId={deckId} - onCreateNote={() => setIsCreateModalOpen(true)} - onImportNotes={() => setIsImportModalOpen(true)} - onEditNote={setEditingNoteId} - onDeleteNote={setDeletingNoteId} - /> + <DeckContent deckId={deckId} /> </Suspense> </ErrorBoundary> </main> - - {/* Modals */} - <CreateNoteModal - isOpen={isCreateModalOpen} - deckId={deckId} - onClose={() => setIsCreateModalOpen(false)} - onNoteCreated={handleCardMutation} - /> - - <ImportNotesModal - isOpen={isImportModalOpen} - deckId={deckId} - onClose={() => setIsImportModalOpen(false)} - onImportComplete={handleCardMutation} - /> - - <EditCardModal - isOpen={editingCard !== null} - deckId={deckId} - card={editingCard} - onClose={() => setEditingCard(null)} - onCardUpdated={handleCardMutation} - /> - - <EditNoteModal - isOpen={editingNoteId !== null} - deckId={deckId} - noteId={editingNoteId} - onClose={() => setEditingNoteId(null)} - onNoteUpdated={handleCardMutation} - /> - - <DeleteCardModal - isOpen={deletingCard !== null} - deckId={deckId} - card={deletingCard} - onClose={() => setDeletingCard(null)} - onCardDeleted={handleCardMutation} - /> - - <DeleteNoteModal - isOpen={deletingNoteId !== null} - deckId={deckId} - noteId={deletingNoteId} - onClose={() => setDeletingNoteId(null)} - onNoteDeleted={handleCardMutation} - /> </div> ); } |
