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.tsx494
1 files changed, 228 insertions, 266 deletions
diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx
index f9b50f2..1376fab 100644
--- a/src/client/pages/DeckDetailPage.tsx
+++ b/src/client/pages/DeckDetailPage.tsx
@@ -6,44 +6,25 @@ import {
faLayerGroup,
faPen,
faPlus,
- faSpinner,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useAtomValue, useSetAtom } from "jotai";
+import { Suspense, useMemo, useState, useTransition } from "react";
import { Link, useParams } from "wouter";
-import { ApiClientError, apiClient } from "../api";
+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";
-
-interface Card {
- id: string;
- deckId: string;
- noteId: string;
- isReversed: boolean;
- front: string;
- back: string;
- state: number;
- due: string;
- reps: number;
- lapses: number;
- createdAt: string;
- updatedAt: string;
-}
+import { LoadingSpinner } from "../components/LoadingSpinner";
/** Combined type for display: note group */
type CardDisplayItem = { type: "note"; noteId: string; cards: Card[] };
-interface Deck {
- id: string;
- name: string;
- description: string | null;
-}
-
const CardStateLabels: Record<number, string> = {
0: "New",
1: "Learning",
@@ -178,18 +159,31 @@ function NoteGroupCard({
);
}
-export function DeckDetailPage() {
- const { deckId } = useParams<{ deckId: string }>();
- const [deck, setDeck] = useState<Deck | null>(null);
- const [cards, setCards] = useState<Card[]>([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- 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);
+function DeckHeader({ deckId }: { deckId: string }) {
+ const deck = useAtomValue(deckByIdAtomFamily(deckId));
+
+ return (
+ <div className="mb-8">
+ <h1 className="font-display text-3xl font-semibold text-ink mb-2">
+ {deck.name}
+ </h1>
+ {deck.description && <p className="text-muted">{deck.description}</p>}
+ </div>
+ );
+}
+
+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[] => {
@@ -230,46 +224,153 @@ export function DeckDetailPage() {
return items;
}, [cards]);
- const fetchDeck = useCallback(async () => {
- if (!deckId) return;
+ 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"
+ />
+ </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>
+ );
+ }
- const res = await apiClient.rpc.api.decks[":id"].$get({
- param: { id: deckId },
- });
- const data = await apiClient.handleResponse<{ deck: Deck }>(res);
- setDeck(data.deck);
- }, [deckId]);
+ 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>
+ );
+}
- const fetchCards = useCallback(async () => {
- if (!deckId) return;
+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));
- const res = await apiClient.rpc.api.decks[":deckId"].cards.$get({
- param: { deckId },
- });
- const data = await apiClient.handleResponse<{ cards: Card[] }>(res);
- setCards(data.cards);
- }, [deckId]);
-
- const fetchData = useCallback(async () => {
- setIsLoading(true);
- setError(null);
-
- try {
- await Promise.all([fetchDeck(), fetchCards()]);
- } catch (err) {
- if (err instanceof ApiClientError) {
- setError(err.message);
- } else {
- setError("Failed to load data. Please try again.");
- }
- } finally {
- setIsLoading(false);
- }
- }, [fetchDeck, fetchCards]);
+ return (
+ <div className="animate-fade-in">
+ {/* Deck Header */}
+ <ErrorBoundary>
+ <Suspense fallback={<LoadingSpinner />}>
+ <DeckHeader deckId={deckId} />
+ </Suspense>
+ </ErrorBoundary>
+
+ {/* Study Button */}
+ <div className="mb-8">
+ <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"
+ >
+ <FontAwesomeIcon
+ icon={faCirclePlay}
+ className="w-5 h-5"
+ aria-hidden="true"
+ />
+ Study Now
+ </Link>
+ </div>
- useEffect(() => {
- fetchData();
- }, [fetchData]);
+ {/* 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>
+ </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 (
@@ -308,204 +409,65 @@ export function DeckDetailPage() {
{/* Main Content */}
<main className="max-w-4xl mx-auto px-4 py-8">
- {/* Loading State */}
- {isLoading && (
- <div className="flex items-center justify-center py-12">
- <FontAwesomeIcon
- icon={faSpinner}
- className="h-8 w-8 text-primary animate-spin"
- aria-hidden="true"
+ <ErrorBoundary>
+ <Suspense fallback={<LoadingSpinner />}>
+ <DeckContent
+ deckId={deckId}
+ onCreateNote={() => setIsCreateModalOpen(true)}
+ onImportNotes={() => setIsImportModalOpen(true)}
+ onEditNote={setEditingNoteId}
+ onDeleteNote={setDeletingNoteId}
/>
- </div>
- )}
-
- {/* Error State */}
- {error && (
- <div
- role="alert"
- className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between"
- >
- <span className="text-error">{error}</span>
- <button
- type="button"
- onClick={fetchData}
- className="text-error hover:text-error/80 font-medium text-sm"
- >
- Retry
- </button>
- </div>
- )}
-
- {/* Deck Content */}
- {!isLoading && !error && deck && (
- <div className="animate-fade-in">
- {/* Deck Header */}
- <div className="mb-8">
- <h1 className="font-display text-3xl font-semibold text-ink mb-2">
- {deck.name}
- </h1>
- {deck.description && (
- <p className="text-muted">{deck.description}</p>
- )}
- </div>
-
- {/* Study Button */}
- <div className="mb-8">
- <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"
- >
- <FontAwesomeIcon
- icon={faCirclePlay}
- className="w-5 h-5"
- aria-hidden="true"
- />
- Study Now
- </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={() => setIsImportModalOpen(true)}
- 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={() => setIsCreateModalOpen(true)}
- 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>
- </div>
-
- {/* Empty State */}
- {cards.length === 0 && (
- <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"
- />
- </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={() => setIsCreateModalOpen(true)}
- 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>
- )}
-
- {/* Card List - Grouped by Note */}
- {cards.length > 0 && (
- <div className="space-y-4">
- {displayItems.map((item, index) => (
- <NoteGroupCard
- key={item.noteId}
- noteId={item.noteId}
- cards={item.cards}
- index={index}
- onEditNote={() => setEditingNoteId(item.noteId)}
- onDeleteNote={() => setDeletingNoteId(item.noteId)}
- />
- ))}
- </div>
- )}
- </div>
- )}
+ </Suspense>
+ </ErrorBoundary>
</main>
{/* Modals */}
- {deckId && (
- <CreateNoteModal
- isOpen={isCreateModalOpen}
- deckId={deckId}
- onClose={() => setIsCreateModalOpen(false)}
- onNoteCreated={fetchCards}
- />
- )}
-
- {deckId && (
- <ImportNotesModal
- isOpen={isImportModalOpen}
- deckId={deckId}
- onClose={() => setIsImportModalOpen(false)}
- onImportComplete={fetchCards}
- />
- )}
-
- {deckId && (
- <EditCardModal
- isOpen={editingCard !== null}
- deckId={deckId}
- card={editingCard}
- onClose={() => setEditingCard(null)}
- onCardUpdated={fetchCards}
- />
- )}
-
- {deckId && (
- <EditNoteModal
- isOpen={editingNoteId !== null}
- deckId={deckId}
- noteId={editingNoteId}
- onClose={() => setEditingNoteId(null)}
- onNoteUpdated={fetchCards}
- />
- )}
-
- {deckId && (
- <DeleteCardModal
- isOpen={deletingCard !== null}
- deckId={deckId}
- card={deletingCard}
- onClose={() => setDeletingCard(null)}
- onCardDeleted={fetchCards}
- />
- )}
-
- {deckId && (
- <DeleteNoteModal
- isOpen={deletingNoteId !== null}
- deckId={deckId}
- noteId={deletingNoteId}
- onClose={() => setDeletingNoteId(null)}
- onNoteDeleted={fetchCards}
- />
- )}
+ <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>
);
}