diff options
| -rw-r--r-- | docs/dev/roadmap.md | 2 | ||||
| -rw-r--r-- | src/client/components/DeleteNoteModal.test.tsx | 234 | ||||
| -rw-r--r-- | src/client/components/DeleteNoteModal.tsx | 152 | ||||
| -rw-r--r-- | src/client/pages/DeckDetailPage.test.tsx | 307 | ||||
| -rw-r--r-- | src/client/pages/DeckDetailPage.tsx | 394 |
5 files changed, 997 insertions, 92 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index cbff279..7f65ccf 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -254,7 +254,7 @@ Create these as default note types for each user: - [ ] Update EditCardModal → EditNoteModal - Load note and field values - Update all generated cards on save -- [ ] Update DeckDetailPage +- [x] Update DeckDetailPage - Group cards by note - Show note-level actions (edit note, delete note) - Display whether card is normal or reversed diff --git a/src/client/components/DeleteNoteModal.test.tsx b/src/client/components/DeleteNoteModal.test.tsx new file mode 100644 index 0000000..85aaa14 --- /dev/null +++ b/src/client/components/DeleteNoteModal.test.tsx @@ -0,0 +1,234 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { apiClient } from "../api/client"; +import { DeleteNoteModal } from "./DeleteNoteModal"; + +vi.mock("../api/client", () => ({ + apiClient: { + getAuthHeader: vi.fn(), + }, + ApiClientError: class ApiClientError extends Error { + constructor( + message: string, + public status: number, + public code?: string, + ) { + super(message); + this.name = "ApiClientError"; + } + }, +})); + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe("DeleteNoteModal", () => { + const defaultProps = { + isOpen: true, + deckId: "deck-1", + noteId: "note-1", + onClose: vi.fn(), + onNoteDeleted: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(apiClient.getAuthHeader).mockReturnValue({ + Authorization: "Bearer access-token", + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders delete confirmation dialog", () => { + render(<DeleteNoteModal {...defaultProps} />); + + expect(screen.getByRole("dialog")).toBeDefined(); + expect(screen.getByRole("heading", { name: "Delete Note" })).toBeDefined(); + expect( + screen.getByText("Are you sure you want to delete this note?"), + ).toBeDefined(); + expect( + screen.getByText( + "This will delete all cards generated from this note. This action cannot be undone.", + ), + ).toBeDefined(); + }); + + it("renders Cancel and Delete buttons", () => { + render(<DeleteNoteModal {...defaultProps} />); + + expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Delete" })).toBeDefined(); + }); + + it("does not render when isOpen is false", () => { + render(<DeleteNoteModal {...defaultProps} isOpen={false} />); + + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("does not render when noteId is null", () => { + render(<DeleteNoteModal {...defaultProps} noteId={null} />); + + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("calls onClose when Cancel button is clicked", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + + render(<DeleteNoteModal {...defaultProps} onClose={onClose} />); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("calls onClose when backdrop is clicked", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + + render(<DeleteNoteModal {...defaultProps} onClose={onClose} />); + + // Click the backdrop (the dialog container) + const dialog = screen.getByRole("dialog"); + await user.click(dialog); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("calls onClose when Escape key is pressed on the dialog", async () => { + const onClose = vi.fn(); + + render(<DeleteNoteModal {...defaultProps} onClose={onClose} />); + + // The dialog has onKeyDown handler - fire a keyboard event directly + const dialog = screen.getByRole("dialog"); + const event = new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + }); + dialog.dispatchEvent(event); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("deletes note and calls callbacks on success", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onNoteDeleted = vi.fn(); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }); + + render( + <DeleteNoteModal + {...defaultProps} + onClose={onClose} + onNoteDeleted={onNoteDeleted} + />, + ); + + await user.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", { + method: "DELETE", + headers: { Authorization: "Bearer access-token" }, + }); + }); + + expect(onNoteDeleted).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("displays error message when delete fails", async () => { + const user = userEvent.setup(); + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: "Failed to delete note" }), + }); + + render(<DeleteNoteModal {...defaultProps} />); + + await user.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain( + "Failed to delete note", + ); + }); + }); + + it("shows Deleting... text while deleting", async () => { + const user = userEvent.setup(); + + // Create a promise that we can control + let resolveDelete: (value: unknown) => void; + const deletePromise = new Promise((resolve) => { + resolveDelete = resolve; + }); + + mockFetch.mockReturnValueOnce(deletePromise); + + render(<DeleteNoteModal {...defaultProps} />); + + await user.click(screen.getByRole("button", { name: "Delete" })); + + // Should show "Deleting..." while request is in progress + expect(screen.getByText("Deleting...")).toBeDefined(); + + // Resolve the delete request to cleanup + if (resolveDelete) { + resolveDelete({ + ok: true, + json: async () => ({ success: true }), + }); + } + }); + + it("disables buttons while deleting", async () => { + const user = userEvent.setup(); + + // Create a promise that we can control + let resolveDelete: (value: unknown) => void; + const deletePromise = new Promise((resolve) => { + resolveDelete = resolve; + }); + + mockFetch.mockReturnValueOnce(deletePromise); + + render(<DeleteNoteModal {...defaultProps} />); + + await user.click(screen.getByRole("button", { name: "Delete" })); + + // Both buttons should be disabled + expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty( + "disabled", + true, + ); + expect(screen.getByText("Deleting...").closest("button")).toHaveProperty( + "disabled", + true, + ); + + // Resolve the delete request to cleanup + if (resolveDelete) { + resolveDelete({ + ok: true, + json: async () => ({ success: true }), + }); + } + }); +}); diff --git a/src/client/components/DeleteNoteModal.tsx b/src/client/components/DeleteNoteModal.tsx new file mode 100644 index 0000000..8eec124 --- /dev/null +++ b/src/client/components/DeleteNoteModal.tsx @@ -0,0 +1,152 @@ +import { useState } from "react"; +import { ApiClientError, apiClient } from "../api"; + +interface DeleteNoteModalProps { + isOpen: boolean; + deckId: string; + noteId: string | null; + onClose: () => void; + onNoteDeleted: () => void; +} + +export function DeleteNoteModal({ + isOpen, + deckId, + noteId, + onClose, + onNoteDeleted, +}: DeleteNoteModalProps) { + const [error, setError] = useState<string | null>(null); + const [isDeleting, setIsDeleting] = useState(false); + + const handleClose = () => { + setError(null); + onClose(); + }; + + const handleDelete = async () => { + if (!noteId) return; + + setError(null); + setIsDeleting(true); + + try { + const authHeader = apiClient.getAuthHeader(); + if (!authHeader) { + throw new ApiClientError("Not authenticated", 401); + } + + const res = await fetch(`/api/decks/${deckId}/notes/${noteId}`, { + method: "DELETE", + headers: authHeader, + }); + + if (!res.ok) { + const errorBody = await res.json().catch(() => ({})); + throw new ApiClientError( + (errorBody as { error?: string }).error || + `Request failed with status ${res.status}`, + res.status, + ); + } + + onNoteDeleted(); + onClose(); + } catch (err) { + if (err instanceof ApiClientError) { + setError(err.message); + } else { + setError("Failed to delete note. Please try again."); + } + } finally { + setIsDeleting(false); + } + }; + + if (!isOpen || !noteId) { + return null; + } + + return ( + <div + role="dialog" + aria-modal="true" + aria-labelledby="delete-note-title" + className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in" + onClick={(e) => { + if (e.target === e.currentTarget) { + handleClose(); + } + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + handleClose(); + } + }} + > + <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in"> + <div className="p-6"> + <div className="w-12 h-12 mx-auto mb-4 bg-error/10 rounded-full flex items-center justify-center"> + <svg + className="w-6 h-6 text-error" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" + /> + </svg> + </div> + + <h2 + id="delete-note-title" + className="font-display text-xl font-medium text-ink text-center mb-2" + > + Delete Note + </h2> + + {error && ( + <div + role="alert" + className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20 mb-4" + > + {error} + </div> + )} + + <p className="text-slate text-center mb-2"> + Are you sure you want to delete this note? + </p> + <p className="text-muted text-sm text-center mb-6"> + This will delete all cards generated from this note. This action + cannot be undone. + </p> + + <div className="flex gap-3 justify-center"> + <button + type="button" + onClick={handleClose} + disabled={isDeleting} + className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50 min-w-[100px]" + > + Cancel + </button> + <button + type="button" + onClick={handleDelete} + disabled={isDeleting} + className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]" + > + {isDeleting ? "Deleting..." : "Delete"} + </button> + </div> + </div> + </div> + </div> + ); +} diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx index a6b8531..35303d9 100644 --- a/src/client/pages/DeckDetailPage.test.tsx +++ b/src/client/pages/DeckDetailPage.test.tsx @@ -51,10 +51,13 @@ const mockDeck = { updatedAt: "2024-01-01T00:00:00Z", }; -const mockCards = [ +// Legacy cards (no noteId) for backward compatibility testing +const mockLegacyCards = [ { id: "card-1", deckId: "deck-1", + noteId: null, + isReversed: null, front: "Hello", back: "こんにちは", state: 0, @@ -74,6 +77,8 @@ const mockCards = [ { id: "card-2", deckId: "deck-1", + noteId: null, + isReversed: null, front: "Goodbye", back: "さようなら", state: 2, @@ -92,6 +97,58 @@ const mockCards = [ }, ]; +// Note-based cards (with noteId) +const mockNoteBasedCards = [ + { + id: "card-3", + deckId: "deck-1", + noteId: "note-1", + isReversed: false, + front: "Apple", + back: "りんご", + state: 0, + due: "2024-01-01T00:00:00Z", + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: "2024-01-02T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + deletedAt: null, + syncVersion: 0, + }, + { + id: "card-4", + deckId: "deck-1", + noteId: "note-1", + isReversed: true, + front: "りんご", + back: "Apple", + state: 0, + due: "2024-01-01T00:00:00Z", + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 2, + lapses: 0, + lastReview: null, + createdAt: "2024-01-02T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + deletedAt: null, + syncVersion: 0, + }, +]; + +// Mixed cards (both legacy and note-based) +const mockMixedCards = [...mockLegacyCards, ...mockNoteBasedCards]; + +// Alias for backward compatibility in existing tests +const mockCards = mockLegacyCards; + function renderWithProviders(path = "/decks/deck-1") { const { hook } = memoryLocation({ path, static: true }); return render( @@ -583,4 +640,252 @@ describe("DeckDetailPage", () => { }); }); }); + + describe("Card Grouping by Note", () => { + it("groups cards by noteId and displays as note groups", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + // Should show note group container + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + // Should display both cards within the note group + const noteCards = screen.getAllByTestId("note-card"); + expect(noteCards.length).toBe(2); + }); + + it("displays legacy cards separately from note groups", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockMixedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + // Should show both note groups and legacy cards + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + const legacyCards = screen.getAllByTestId("legacy-card"); + expect(legacyCards.length).toBe(2); // 2 legacy cards + + // Should show "Legacy" badge for legacy cards + const legacyBadges = screen.getAllByText("Legacy"); + expect(legacyBadges.length).toBe(2); + }); + + it("shows Normal and Reversed badges for note-based cards", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Normal")).toBeDefined(); + }); + + expect(screen.getByText("Reversed")).toBeDefined(); + }); + + it("shows note card count in note group header", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + // Should show "Note (2 cards)" since there are 2 cards from the same note + expect(screen.getByText("Note (2 cards)")).toBeDefined(); + }); + }); + + it("shows edit note button for note groups", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + const editNoteButton = screen.getByRole("button", { name: "Edit note" }); + expect(editNoteButton).toBeDefined(); + }); + + it("shows delete note button for note groups", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + const deleteNoteButton = screen.getByRole("button", { + name: "Delete note", + }); + expect(deleteNoteButton).toBeDefined(); + }); + + it("opens delete note modal when delete button is clicked", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + const deleteNoteButton = screen.getByRole("button", { + name: "Delete note", + }); + await user.click(deleteNoteButton); + + expect(screen.getByRole("dialog")).toBeDefined(); + expect( + screen.getByRole("heading", { name: "Delete Note" }), + ).toBeDefined(); + }); + + it("deletes note and refreshes list when confirmed", async () => { + const user = userEvent.setup(); + + mockFetch + // Initial load + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }) + // Delete request + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }) + // Refresh cards after deletion + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: [] }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + const deleteNoteButton = screen.getByRole("button", { + name: "Delete note", + }); + await user.click(deleteNoteButton); + + // Confirm deletion in modal + const dialog = screen.getByRole("dialog"); + const modalButtons = dialog.querySelectorAll("button"); + const confirmDeleteButton = Array.from(modalButtons).find((btn) => + btn.textContent?.includes("Delete"), + ); + if (confirmDeleteButton) { + await user.click(confirmDeleteButton); + } + + // Wait for modal to close + await waitFor(() => { + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + // Verify DELETE request was made to notes endpoint + expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", { + method: "DELETE", + headers: { Authorization: "Bearer access-token" }, + }); + + // Should show empty state after deletion + await waitFor(() => { + expect(screen.getByText("No cards yet")).toBeDefined(); + }); + }); + + it("displays note preview from normal card content", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + // The normal card's front/back should be displayed as preview + expect(screen.getByText("Apple")).toBeDefined(); + expect(screen.getByText("りんご")).toBeDefined(); + }); + }); }); diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx index a06fcc7..87f9dc3 100644 --- a/src/client/pages/DeckDetailPage.tsx +++ b/src/client/pages/DeckDetailPage.tsx @@ -2,17 +2,19 @@ import { faChevronLeft, faCirclePlay, faFile, + faLayerGroup, faPen, faPlus, faSpinner, faTrash, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Link, useParams } from "wouter"; import { ApiClientError, apiClient } from "../api"; 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"; @@ -31,6 +33,11 @@ interface Card { updatedAt: string; } +/** Combined type for display: either a note group or a legacy card */ +type CardDisplayItem = + | { type: "note"; noteId: string; cards: Card[] } + | { type: "legacy"; card: Card }; + interface Deck { id: string; name: string; @@ -51,6 +58,215 @@ const CardStateColors: Record<number, string> = { 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> + ); +} + +/** Component for displaying a legacy card (without note association) */ +function LegacyCardItem({ + card, + index, + onEdit, + onDelete, +}: { + card: Card; + index: number; + onEdit: () => void; + onDelete: () => void; +}) { + return ( + <div + data-testid="legacy-card" + className="bg-white rounded-xl border border-border/50 p-5 shadow-card hover:shadow-md transition-all duration-200" + style={{ animationDelay: `${index * 30}ms` }} + > + <div className="flex items-start justify-between gap-4"> + <div className="flex-1 min-w-0"> + {/* Front/Back Preview */} + <div className="grid grid-cols-2 gap-4 mb-3"> + <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"> + {card.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"> + {card.back} + </p> + </div> + </div> + + {/* Card Stats */} + <div className="flex items-center gap-3 text-xs"> + <span + className={`px-2 py-0.5 rounded-full font-medium ${CardStateColors[card.state] || "bg-muted/10 text-muted"}`} + > + {CardStateLabels[card.state] || "Unknown"} + </span> + <span className="px-2 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700"> + Legacy + </span> + <span className="text-muted">{card.reps} reviews</span> + {card.lapses > 0 && ( + <span className="text-muted">{card.lapses} lapses</span> + )} + </div> + </div> + + {/* Actions */} + <div className="flex items-center gap-1 shrink-0"> + <button + type="button" + onClick={onEdit} + className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors" + title="Edit card" + > + <FontAwesomeIcon + icon={faPen} + className="w-4 h-4" + aria-hidden="true" + /> + </button> + <button + type="button" + onClick={onDelete} + className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors" + title="Delete card" + > + <FontAwesomeIcon + icon={faTrash} + className="w-4 h-4" + aria-hidden="true" + /> + </button> + </div> + </div> + </div> + ); +} + export function DeckDetailPage() { const { deckId } = useParams<{ deckId: string }>(); const [deck, setDeck] = useState<Deck | null>(null); @@ -61,6 +277,61 @@ export function DeckDetailPage() { 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); + + // Group cards by note for display + const displayItems = useMemo((): CardDisplayItem[] => { + const noteGroups = new Map<string, Card[]>(); + const legacyCards: Card[] = []; + + for (const card of cards) { + if (card.noteId) { + const existing = noteGroups.get(card.noteId); + if (existing) { + existing.push(card); + } else { + noteGroups.set(card.noteId, [card]); + } + } else { + legacyCards.push(card); + } + } + + const items: CardDisplayItem[] = []; + + // Add note groups first, sorted by earliest card creation + 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 + }, + ); + + 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 }); + } + + // Add legacy cards, newest first + legacyCards.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + for (const card of legacyCards) { + items.push({ type: "legacy", card }); + } + + return items; + }, [cards]); const fetchDeck = useCallback(async () => { if (!deckId) return; @@ -277,96 +548,29 @@ export function DeckDetailPage() { </div> )} - {/* Card List */} + {/* Card List - Grouped by Note */} {cards.length > 0 && ( - <div className="space-y-3"> - {cards.map((card, index) => ( - <div - key={card.id} - className="bg-white rounded-xl border border-border/50 p-5 shadow-card hover:shadow-md transition-all duration-200" - style={{ animationDelay: `${index * 30}ms` }} - > - <div className="flex items-start justify-between gap-4"> - <div className="flex-1 min-w-0"> - {/* Front/Back Preview */} - <div className="grid grid-cols-2 gap-4 mb-3"> - <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"> - {card.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"> - {card.back} - </p> - </div> - </div> - - {/* Card Stats */} - <div className="flex items-center gap-3 text-xs"> - <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-slate/10 text-slate"> - Reversed - </span> - )} - <span className="text-muted"> - {card.reps} reviews - </span> - {card.lapses > 0 && ( - <span className="text-muted"> - {card.lapses} lapses - </span> - )} - </div> - </div> - - {/* Actions */} - <div className="flex items-center gap-1 shrink-0"> - <button - type="button" - onClick={() => { - if (card.noteId) { - setEditingNoteId(card.noteId); - } else { - setEditingCard(card); - } - }} - className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors" - title={card.noteId ? "Edit note" : "Edit card"} - > - <FontAwesomeIcon - icon={faPen} - className="w-4 h-4" - aria-hidden="true" - /> - </button> - <button - type="button" - onClick={() => setDeletingCard(card)} - className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors" - title="Delete card" - > - <FontAwesomeIcon - icon={faTrash} - className="w-4 h-4" - aria-hidden="true" - /> - </button> - </div> - </div> - </div> - ))} + <div className="space-y-4"> + {displayItems.map((item, index) => + item.type === "note" ? ( + <NoteGroupCard + key={item.noteId} + noteId={item.noteId} + cards={item.cards} + index={index} + onEditNote={() => setEditingNoteId(item.noteId)} + onDeleteNote={() => setDeletingNoteId(item.noteId)} + /> + ) : ( + <LegacyCardItem + key={item.card.id} + card={item.card} + index={index} + onEdit={() => setEditingCard(item.card)} + onDelete={() => setDeletingCard(item.card)} + /> + ), + )} </div> )} </div> @@ -412,6 +616,16 @@ export function DeckDetailPage() { onCardDeleted={fetchCards} /> )} + + {deckId && ( + <DeleteNoteModal + isOpen={deletingNoteId !== null} + deckId={deckId} + noteId={deletingNoteId} + onClose={() => setDeletingNoteId(null)} + onNoteDeleted={fetchCards} + /> + )} </div> ); } |
