From 58cad6149864cb08ee08eb9d9873529e30c5f908 Mon Sep 17 00:00:00 2001 From: Ali Date: Wed, 27 May 2026 14:51:36 -0500 Subject: [PATCH 1/5] Redesign flashcard deck workflows --- app/(app)/flashcards/page.tsx | 33 +- app/api/flashcards/route.ts | 214 ++++-- app/flashcards/flashcards-client.test.tsx | 224 ++++++ app/flashcards/flashcards-client.tsx | 789 ++++++++++++++++----- components/note-editor/code-block-view.tsx | 2 +- components/note-editor/note-renderer.tsx | 49 +- lib/flashcards/decks.ts | 71 ++ lib/flashcards/types.ts | 1 + 8 files changed, 1080 insertions(+), 303 deletions(-) create mode 100644 app/flashcards/flashcards-client.test.tsx create mode 100644 lib/flashcards/decks.ts diff --git a/app/(app)/flashcards/page.tsx b/app/(app)/flashcards/page.tsx index ba6d04c..bb18c6d 100644 --- a/app/(app)/flashcards/page.tsx +++ b/app/(app)/flashcards/page.tsx @@ -4,28 +4,7 @@ import { db } from "@/lib/db"; import { flashcards, note } from "@/lib/db/schema"; import { requireServerSession } from "@/lib/auth-session"; import { FlashcardsClient } from "@/app/flashcards/flashcards-client"; -import type { FlashcardDeck, FlashcardItem } from "@/lib/flashcards/types"; - -function normalizeCards(value: unknown): FlashcardItem[] { - if (!Array.isArray(value)) { - return []; - } - - return value.filter((item): item is FlashcardItem => { - if (!item || typeof item !== "object") { - return false; - } - - const candidate = item as Partial; - return ( - typeof candidate.id === "string" && - typeof candidate.front === "string" && - typeof candidate.back === "string" && - Array.isArray(candidate.sourceNoteTitles) && - candidate.sourceNoteTitles.every((title) => typeof title === "string") - ); - }); -} +import { rowToFlashcardDeck } from "@/lib/flashcards/decks"; export default async function FlashcardsPage() { await connection(); @@ -57,15 +36,7 @@ export default async function FlashcardsPage() { .orderBy(desc(flashcards.createdAt)), ]); - const decks: FlashcardDeck[] = deckRows.map((deck) => ({ - id: deck.id, - title: deck.title, - sourceNoteIds: deck.sourceNoteIds, - cards: normalizeCards(deck.cards), - cardCount: deck.cardCount, - createdAt: deck.createdAt.toISOString(), - updatedAt: deck.updatedAt.toISOString(), - })); + const decks = deckRows.map(rowToFlashcardDeck); return ( note.embedding.length === 0)) { + return { error: "Selected notes do not have stored embeddings yet", status: 400 } as const; + } + + const generatedDeck = await generateFlashcardDeck({ + notes: contextNotes, + cardCount: params.cardCount, + }); + return { - id: row.id, - title: row.title, - sourceNoteIds: row.sourceNoteIds, - cards: normalizeCards(row.cards), - cardCount: row.cardCount, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; + deck: createDraftFlashcardDeck({ + title: generatedDeck.title, + sourceNoteIds: uniqueNoteIds, + cards: generatedDeck.cards, + }), + } as const; } export async function GET() { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await requireSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -68,11 +81,11 @@ export async function GET() { .where(eq(flashcards.userId, session.user.id)) .orderBy(desc(flashcards.createdAt)); - return NextResponse.json({ decks: rows.map(rowToDeck) }); + return NextResponse.json({ decks: rows.map(rowToFlashcardDeck) }); } export async function POST(req: Request) { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await requireSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -84,48 +97,129 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } - const parsed = createFlashcardsSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid flashcard generation options" }, { status: 400 }); - } - - const uniqueNoteIds = Array.from(new Set(parsed.data.noteIds)); - const contextNotes = await getFlashcardContextNotes({ - userId: session.user.id, - noteIds: uniqueNoteIds, - }); - - if (contextNotes.length !== uniqueNoteIds.length) { - return NextResponse.json({ error: "One or more selected notes could not be found" }, { status: 404 }); - } - - if (contextNotes.every((note) => note.embedding.length === 0)) { - return NextResponse.json( - { error: "Selected notes do not have stored embeddings yet" }, - { status: 400 }, - ); - } + const generateParsed = generatePreviewSchema.safeParse(body); + const saveParsed = saveDeckSchema.safeParse(body); + const legacyParsed = legacyCreateFlashcardsSchema.safeParse(body); try { - const generatedDeck = await generateFlashcardDeck({ - notes: contextNotes, - cardCount: parsed.data.cardCount, + if (generateParsed.success) { + const result = await generateDeckPreview({ + userId: session.user.id, + noteIds: generateParsed.data.noteIds, + cardCount: generateParsed.data.cardCount, + }); + + if ("error" in result) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + + return NextResponse.json({ deck: result.deck, saved: false }); + } + + if (saveParsed.success) { + const [created] = await db + .insert(flashcards) + .values({ + userId: session.user.id, + title: saveParsed.data.title, + sourceNoteIds: saveParsed.data.sourceNoteIds, + cards: saveParsed.data.cards, + cardCount: saveParsed.data.cards.length, + }) + .returning(); + + return NextResponse.json({ deck: rowToFlashcardDeck(created) }, { status: 201 }); + } + + if (!legacyParsed.success) { + return NextResponse.json({ error: "Invalid flashcard request" }, { status: 400 }); + } + + const result = await generateDeckPreview({ + userId: session.user.id, + noteIds: legacyParsed.data.noteIds, + cardCount: legacyParsed.data.cardCount, }); + if ("error" in result) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + const [created] = await db .insert(flashcards) .values({ userId: session.user.id, - title: generatedDeck.title, - sourceNoteIds: uniqueNoteIds, - cards: generatedDeck.cards, - cardCount: generatedDeck.cards.length, + title: result.deck.title, + sourceNoteIds: result.deck.sourceNoteIds, + cards: result.deck.cards, + cardCount: result.deck.cards.length, }) .returning(); - return NextResponse.json({ deck: rowToDeck(created) }, { status: 201 }); + return NextResponse.json({ deck: rowToFlashcardDeck(created) }, { status: 201 }); } catch (error) { const message = error instanceof Error ? error.message : "Flashcard generation failed"; return NextResponse.json({ error: message }, { status: 500 }); } } + +export async function PATCH(req: Request) { + const session = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = updateDeckSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid flashcard deck" }, { status: 400 }); + } + + const [updated] = await db + .update(flashcards) + .set({ + title: parsed.data.title, + sourceNoteIds: parsed.data.sourceNoteIds, + cards: parsed.data.cards, + cardCount: parsed.data.cards.length, + updatedAt: new Date(), + }) + .where(and(eq(flashcards.id, parsed.data.deckId), eq(flashcards.userId, session.user.id))) + .returning(); + + if (!updated) { + return NextResponse.json({ error: "Flashcard deck not found" }, { status: 404 }); + } + + return NextResponse.json({ deck: rowToFlashcardDeck(updated) }); +} + +export async function DELETE(req: Request) { + const session = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const deckId = new URL(req.url).searchParams.get("deckId"); + const parsedDeckId = z.string().trim().min(1).safeParse(deckId); + if (!parsedDeckId.success) { + return NextResponse.json({ error: "Missing flashcard deck id" }, { status: 400 }); + } + + const [deleted] = await db + .delete(flashcards) + .where(and(eq(flashcards.id, parsedDeckId.data), eq(flashcards.userId, session.user.id))) + .returning(); + + if (!deleted) { + return NextResponse.json({ error: "Flashcard deck not found" }, { status: 404 }); + } + + return NextResponse.json({ deletedDeckId: deleted.id }); +} diff --git a/app/flashcards/flashcards-client.test.tsx b/app/flashcards/flashcards-client.test.tsx new file mode 100644 index 0000000..4e7e271 --- /dev/null +++ b/app/flashcards/flashcards-client.test.tsx @@ -0,0 +1,224 @@ +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 type { FlashcardDeck } from "@/lib/flashcards/types"; + +vi.mock("react-markdown", () => ({ + default: ({ children }: { children: string }) => <>{children}, +})); + +vi.mock("rehype-katex", () => ({ + default: () => null, +})); + +vi.mock("remark-gfm", () => ({ + default: () => null, +})); + +vi.mock("remark-math", () => ({ + default: () => null, +})); + +import { FlashcardsClient } from "@/app/flashcards/flashcards-client"; + +const notes = [ + { + id: "note-1", + title: "Lecture One", + hasEmbedding: true, + }, + { + id: "note-2", + title: "Draft Note", + hasEmbedding: false, + }, +]; + +function deck(overrides: Partial = {}): FlashcardDeck { + return { + id: "deck-1", + title: "Biology deck", + sourceNoteIds: ["note-1"], + cards: [ + { + id: "card-1", + front: "What is mitosis?", + back: "Cell division.", + sourceNoteTitles: ["Lecture One"], + tags: ["cells"], + }, + ], + cardCount: 1, + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-02T00:00:00.000Z", + ...overrides, + }; +} + +function jsonResponse(payload: unknown, init?: ResponseInit) { + return { + ok: init?.status ? init.status < 400 : true, + status: init?.status ?? 200, + json: async () => payload, + } as Response; +} + +describe("FlashcardsClient", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("keeps My Flashcards focused on saved deck management", () => { + render(); + + expect(screen.getByRole("heading", { name: "My Flashcards" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Biology deck" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Review" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Delete/ })).toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: /Lecture One/ })).not.toBeInTheDocument(); + }); + + it("generates an editable preview before saving a new deck", async () => { + const user = userEvent.setup(); + const generatedDeck = deck({ + id: "draft-1", + title: "Generated deck", + cards: [ + { + id: "generated-card", + front: "Generated front", + back: "Generated back", + sourceNoteTitles: ["Lecture One"], + tags: [], + }, + ], + }); + const savedDeck = deck({ + id: "deck-2", + title: "Edited deck", + cards: [ + { + id: "generated-card", + front: "Edited front", + back: "Generated back", + sourceNoteTitles: ["Lecture One"], + tags: ["core"], + }, + ], + }); + + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse({ deck: generatedDeck, saved: false })) + .mockResolvedValueOnce(jsonResponse({ deck: savedDeck }, { status: 201 })); + + render(); + + await user.click(screen.getByRole("button", { name: "Create flashcards" })); + await user.click(screen.getByRole("checkbox", { name: /Lecture One/ })); + await user.clear(screen.getByLabelText("Cards")); + await user.type(screen.getByLabelText("Cards"), "4"); + await user.click(screen.getByRole("button", { name: /Generate preview/ })); + + expect(await screen.findByDisplayValue("Generated deck")).toBeInTheDocument(); + + await user.clear(screen.getByLabelText("Deck name")); + await user.type(screen.getByLabelText("Deck name"), "Edited deck"); + await user.clear(screen.getByLabelText("Front")); + await user.type(screen.getByLabelText("Front"), "Edited front"); + await user.type(screen.getByLabelText("Tags"), "core"); + await user.click(screen.getByRole("button", { name: /Save deck/ })); + + await screen.findByRole("heading", { name: "Edited deck" }); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body))).toMatchObject({ + mode: "generate", + noteIds: ["note-1"], + cardCount: 4, + }); + expect(JSON.parse(String(vi.mocked(fetch).mock.calls[1][1]?.body))).toMatchObject({ + mode: "save", + title: "Edited deck", + cards: [ + { + front: "Edited front", + back: "Generated back", + tags: ["core"], + }, + ], + }); + }); + + it("edits saved deck metadata and individual cards", async () => { + const user = userEvent.setup(); + const updatedDeck = deck({ + title: "Renamed deck", + cards: [ + { + id: "card-1", + front: "What is mitosis?", + back: "Updated back", + sourceNoteTitles: ["Lecture One"], + tags: ["exam"], + }, + ], + }); + + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ deck: updatedDeck })); + + render(); + + await user.click(screen.getByRole("button", { name: /Edit/ })); + await user.clear(screen.getByLabelText("Deck name")); + await user.type(screen.getByLabelText("Deck name"), "Renamed deck"); + await user.clear(screen.getByLabelText("Back")); + await user.type(screen.getByLabelText("Back"), "Updated back"); + await user.clear(screen.getByLabelText("Tags")); + await user.type(screen.getByLabelText("Tags"), "exam"); + await user.click(screen.getByRole("button", { name: /Save changes/ })); + + await screen.findByRole("heading", { name: "Renamed deck" }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + "/api/flashcards", + expect.objectContaining({ + method: "PATCH", + body: expect.any(String), + }), + ); + expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body))).toMatchObject({ + deckId: "deck-1", + title: "Renamed deck", + cards: [ + { + back: "Updated back", + tags: ["exam"], + }, + ], + }); + }); + + it("deletes decks from the library after confirmation", async () => { + const user = userEvent.setup(); + vi.spyOn(window, "confirm").mockReturnValue(true); + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ deletedDeckId: "deck-1" })); + + render(); + + await user.click(screen.getByRole("button", { name: /Delete/ })); + + await waitFor(() => { + expect(screen.queryByRole("heading", { name: "Biology deck" })).not.toBeInTheDocument(); + }); + expect(screen.getByText("No flashcard decks")).toBeInTheDocument(); + expect(fetch).toHaveBeenCalledWith("/api/flashcards?deckId=deck-1", { method: "DELETE" }); + }); +}); diff --git a/app/flashcards/flashcards-client.tsx b/app/flashcards/flashcards-client.tsx index d99c522..83495d1 100644 --- a/app/flashcards/flashcards-client.tsx +++ b/app/flashcards/flashcards-client.tsx @@ -3,12 +3,17 @@ import { useMemo, useState } from "react"; import { Brain, + Check, ChevronLeft, ChevronRight, + Edit3, Layers3, Loader2, - Play, + Plus, RotateCcw, + Save, + Trash2, + X, } from "lucide-react"; import ReactMarkdown from "react-markdown"; import rehypeKatex from "rehype-katex"; @@ -16,16 +21,11 @@ import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input, Textarea } from "@/components/ui/input"; +import { PageHeader } from "@/components/ui/page-header"; import { cx } from "@/lib/utils"; -import type { FlashcardDeck } from "@/lib/flashcards/types"; +import type { FlashcardDeck, FlashcardItem } from "@/lib/flashcards/types"; type FlashcardNoteOption = { id: string; @@ -38,10 +38,11 @@ type FlashcardsClientProps = { initialDecks: FlashcardDeck[]; }; +type FlashcardView = "library" | "create" | "review" | "edit"; +type CreateStep = "context" | "preview"; + async function readJsonResponse(response: Response): Promise { - const payload = (await response.json().catch(() => null)) as T & { - error?: string; - }; + const payload = (await response.json().catch(() => null)) as T & { error?: string }; if (!response.ok) { throw new Error(payload?.error || "Request failed"); } @@ -49,13 +50,59 @@ async function readJsonResponse(response: Response): Promise { return payload; } -function MarkdownText({ - markdown, - className, -}: { - markdown: string; - className?: string; -}) { +function createLocalCardId() { + if (globalThis.crypto?.randomUUID) { + return `manual-${globalThis.crypto.randomUUID()}`; + } + + return `manual-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function createBlankCard(): FlashcardItem { + return { + id: createLocalCardId(), + front: "", + back: "", + sourceNoteTitles: [], + tags: [], + }; +} + +function cloneDeck(deck: FlashcardDeck): FlashcardDeck { + return { + ...deck, + cards: deck.cards.map((card) => ({ + ...card, + sourceNoteTitles: [...card.sourceNoteTitles], + tags: [...(card.tags ?? [])], + })), + }; +} + +function withCardCount(deck: FlashcardDeck): FlashcardDeck { + return { + ...deck, + cardCount: deck.cards.length, + }; +} + +function formatDeckDate(value: string) { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(value)); +} + +function parseTags(value: string) { + return value + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean) + .slice(0, 8); +} + +function MarkdownText({ markdown, className }: { markdown: string; className?: string }) { return (

{children}

, - ul: ({ children }) => ( -
    {children}
- ), - ol: ({ children }) => ( -
    {children}
- ), + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, code: ({ children }) => ( {children} @@ -82,40 +125,185 @@ function MarkdownText({ ); } -export function FlashcardsClient({ - notes, - initialDecks, -}: FlashcardsClientProps) { - const embeddedNotes = useMemo( - () => notes.filter((note) => note.hasEmbedding), - [notes], +function DeckEditor({ + deck, + disabled, + onChange, +}: { + deck: FlashcardDeck; + disabled?: boolean; + onChange: (deck: FlashcardDeck) => void; +}) { + function updateTitle(title: string) { + onChange(withCardCount({ ...deck, title })); + } + + function updateCard(index: number, patch: Partial) { + onChange( + withCardCount({ + ...deck, + cards: deck.cards.map((card, cardIndex) => (cardIndex === index ? { ...card, ...patch } : card)), + }), + ); + } + + function addCard() { + onChange(withCardCount({ ...deck, cards: [...deck.cards, createBlankCard()] })); + } + + function removeCard(index: number) { + if (deck.cards.length <= 1) { + return; + } + + onChange(withCardCount({ ...deck, cards: deck.cards.filter((_, cardIndex) => cardIndex !== index) })); + } + + return ( +
+ + +
+
+

Cards

+ +
+ +
+ {deck.cards.map((card, index) => ( +
+
+ Card {index + 1} + +
+ +
+