aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages/DeckDetailPage.tsx
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-01-20 01:16:15 +0000
committerClaude <noreply@anthropic.com>2026-01-20 01:16:15 +0000
commit8b212f3030ec30ed68410e609ed55fd7f0b06ea0 (patch)
tree487d93edd18f6544f576bff57f86ad30bf640080 /src/client/pages/DeckDetailPage.tsx
parent188c49e6ae0dfa0af052a001bc40c26d448b1583 (diff)
downloadkioku-8b212f3030ec30ed68410e609ed55fd7f0b06ea0.tar.gz
kioku-8b212f3030ec30ed68410e609ed55fd7f0b06ea0.tar.zst
kioku-8b212f3030ec30ed68410e609ed55fd7f0b06ea0.zip
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
Diffstat (limited to 'src/client/pages/DeckDetailPage.tsx')
-rw-r--r--src/client/pages/DeckDetailPage.tsx416
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>
);
}