aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages/DeckDetailPage.tsx
diff options
context:
space:
mode:
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>
);
}