Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ Use these before reaching for anything external. Do **not** add Radix primitives
| `sonner` | Toasts — `import { toast } from "sonner"` |
| `lucide-react` | Icons — use `Loader2 animate-spin` for loading |
| `react-markdown` + `remark-gfm` + `remark-math` + `rehype-katex` | Markdown with math — reuse the `MarkdownText` component pattern from quizzes/flashcards |
| `@codemirror/*` + `mathlive` + `katex` | The note editor: live-preview Markdown (CM6), structural math editing (MathLive), math rendering (KaTeX). Do not add a second editor or math engine. |
| `zod` v4 | Validation — `safeParse` on API bodies |
| `drizzle-orm` | DB ORM |
| `better-auth` | Auth — `lib/auth.ts`, `lib/auth-client.ts` |
Expand Down Expand Up @@ -374,7 +375,7 @@ These rules come from production React at scale. Violating them causes bugs that

7. **Many study routes are scaffolds** — `/resources`, most `/study/*` pages use `ScaffoldPage` and are not implemented. Don't assume they have real functionality.

8. **Note editor is Editor.js** — complex, debounced, with custom blocks. Hundreds of CSS lines in `globals.css`. Do not touch it without reading `components/note-editor/`.
8. **Note editor is CodeMirror 6 + MathLive** (`components/note-editor/`). Markdown is the canonical note format; the block document in `note.content` is a derived cache. Math regions come from the shared scanner in `lib/notes/math-ranges.ts`, so the server normalizes exactly what the editor renders. Read `components/note-editor/README.md` and `docs/note-editor-requirements.md` before changing either.

9. **Quiz storage guard** — `hasQuizStorage()` returns false if DB migrations haven't run; quiz APIs return 503. The page handles it gracefully — keep that check when adding quiz API routes.

Expand Down
15 changes: 12 additions & 3 deletions app/api/notes/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { db } from "@/lib/db";
import { note } from "@/lib/db/schema";
import { and, eq } from "drizzle-orm";
import { assertClassBelongsToUser } from "@/lib/classes/queries";
import { normalizeNoteWriteContent } from "@/lib/notes/persistence";
import { normalizeNoteWriteContent, normalizeNoteWriteMarkdown } from "@/lib/notes/persistence";

export const runtime = "nodejs";

Expand Down Expand Up @@ -50,7 +50,7 @@ export async function PATCH(req: Request, ctx: RouteContext) {
return NextResponse.json({ error: "Note not found" }, { status: 404 });
}

let body: { title?: string; content?: unknown; classId?: string | null };
let body: { title?: string; content?: unknown; markdown?: unknown; classId?: string | null };
try {
body = await req.json();
} catch {
Expand All @@ -62,7 +62,16 @@ export async function PATCH(req: Request, ctx: RouteContext) {

const updates: Record<string, unknown> = {};
if (typeof body.title === "string") updates.title = body.title.trim() || "Untitled";
if (body.content !== undefined) {
if (body.markdown !== undefined) {
// Canonical path: markdown is stored as authored; blocks are derived.
try {
const content = normalizeNoteWriteMarkdown(body.markdown);
updates.content = content.document;
updates.markdown = content.markdown;
} catch {
return NextResponse.json({ error: "Invalid note markdown" }, { status: 400 });
}
} else if (body.content !== undefined) {
try {
const content = normalizeNoteWriteContent(body.content);
updates.content = content.document;
Expand Down
9 changes: 6 additions & 3 deletions app/api/notes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { db } from "@/lib/db";
import { note } from "@/lib/db/schema";
import { and, desc, eq } from "drizzle-orm";
import { assertClassBelongsToUser } from "@/lib/classes/queries";
import { normalizeNoteWriteContent } from "@/lib/notes/persistence";
import { normalizeNoteWriteContent, normalizeNoteWriteMarkdown } from "@/lib/notes/persistence";

export const runtime = "nodejs";

Expand Down Expand Up @@ -59,7 +59,7 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

let body: { title?: string; content?: unknown; classId?: string | null };
let body: { title?: string; content?: unknown; markdown?: unknown; classId?: string | null };
try {
body = await req.json();
} catch {
Expand All @@ -84,7 +84,10 @@ export async function POST(req: Request) {

let content: ReturnType<typeof normalizeNoteWriteContent>;
try {
content = normalizeNoteWriteContent(body.content);
content =
body.markdown !== undefined
? normalizeNoteWriteMarkdown(body.markdown)
: normalizeNoteWriteContent(body.content);
} catch {
return NextResponse.json(
{ error: "Invalid note content" },
Expand Down
12 changes: 0 additions & 12 deletions app/editor-harness/editor-harness-client.tsx

This file was deleted.

20 changes: 0 additions & 20 deletions app/editor-harness/page.tsx

This file was deleted.

22 changes: 11 additions & 11 deletions app/flashcards/flashcards-client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,9 @@ describe("FlashcardsClient", () => {
it("keeps My Flashcards focused on saved deck management", () => {
render(<FlashcardsClient notes={notes} initialDecks={[deck()]} />);

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.getByText("1 decks")).toBeInTheDocument();
expect(screen.getByText("Biology deck")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Open actions for Biology deck/ })).toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: /Lecture One/ })).not.toBeInTheDocument();
});

Expand Down Expand Up @@ -131,7 +129,7 @@ describe("FlashcardsClient", () => {
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("Front"), "Edited front $√(x²)$");
await user.type(screen.getByLabelText("Tags"), "core");
await user.click(screen.getByRole("button", { name: /Save deck/ }));

Expand All @@ -148,7 +146,7 @@ describe("FlashcardsClient", () => {
title: "Edited deck",
cards: [
{
front: "Edited front",
front: "Edited front $\\sqrt{x^2}$",
back: "Generated back",
tags: ["core"],
},
Expand All @@ -175,7 +173,8 @@ describe("FlashcardsClient", () => {

render(<FlashcardsClient notes={notes} initialDecks={[deck()]} />);

await user.click(screen.getByRole("button", { name: /Edit/ }));
await user.click(screen.getByRole("button", { name: /Open actions for Biology deck/ }));
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"));
Expand Down Expand Up @@ -213,12 +212,13 @@ describe("FlashcardsClient", () => {

render(<FlashcardsClient notes={notes} initialDecks={[deck()]} />);

await user.click(screen.getByRole("button", { name: /Delete/ }));
await user.click(screen.getByRole("button", { name: /Open actions for Biology deck/ }));
await user.click(screen.getByRole("button", { name: "Delete" }));

await waitFor(() => {
expect(screen.queryByRole("heading", { name: "Biology deck" })).not.toBeInTheDocument();
expect(screen.queryByText("Biology deck")).not.toBeInTheDocument();
});
expect(screen.getByText("No flashcard decks")).toBeInTheDocument();
expect(screen.getByText("No saved decks")).toBeInTheDocument();
expect(fetch).toHaveBeenCalledWith("/api/flashcards?deckId=deck-1", { method: "DELETE" });
});
});
84 changes: 38 additions & 46 deletions app/flashcards/flashcards-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,14 @@ import {
Edit3,
Loader2,
MoreHorizontal,
Play,
Plus,
RotateCcw,
Save,
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { LatexMarkdown } from "@/components/math/latex-markdown";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Expand All @@ -32,6 +28,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Input, Textarea } from "@/components/ui/input";
import { normalizeTextMathToLatex } from "@/lib/math/latex";
import { cx } from "@/lib/utils";
import type { FlashcardDeck, FlashcardItem } from "@/lib/flashcards/types";

Expand Down Expand Up @@ -89,6 +86,21 @@ function cloneDeck(deck: FlashcardDeck): FlashcardDeck {
};
}

function normalizeCardMath(card: FlashcardItem): FlashcardItem {
return {
...card,
front: normalizeTextMathToLatex(card.front),
back: normalizeTextMathToLatex(card.back),
};
}

function normalizeDeckMath(deck: FlashcardDeck): FlashcardDeck {
return {
...deck,
cards: deck.cards.map(normalizeCardMath),
};
}

function withCardCount(deck: FlashcardDeck): FlashcardDeck {
return {
...deck,
Expand All @@ -112,39 +124,6 @@ function parseTags(value: string) {
.slice(0, 8);
}

function MarkdownText({
markdown,
className,
}: {
markdown: string;
className?: string;
}) {
return (
<div className={cx("min-w-0 space-y-2 text-foreground", className)}>
<ReactMarkdown
rehypePlugins={[rehypeKatex]}
remarkPlugins={[remarkGfm, remarkMath]}
components={{
p: ({ children }) => <p>{children}</p>,
ul: ({ children }) => (
<ul className="ml-5 list-disc space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="ml-5 list-decimal space-y-1">{children}</ol>
),
code: ({ children }) => (
<code className="rounded bg-surface-elevated px-1 py-0.5 font-mono text-[0.92em]">
{children}
</code>
),
}}
>
{markdown}
</ReactMarkdown>
</div>
);
}

function StatPill({ children }: { children: ReactNode }) {
return (
<span className="inline-flex h-8 items-center rounded-full border border-border bg-transparent px-3 text-sm font-medium text-muted-foreground">
Expand Down Expand Up @@ -252,6 +231,11 @@ function DeckEditor({
onChange={(event) =>
updateCard(index, { front: event.target.value })
}
onBlur={(event) =>
updateCard(index, {
front: normalizeTextMathToLatex(event.target.value),
})
}
/>
</label>
<label className="space-y-2 text-sm font-medium text-foreground">
Expand All @@ -263,6 +247,11 @@ function DeckEditor({
onChange={(event) =>
updateCard(index, { back: event.target.value })
}
onBlur={(event) =>
updateCard(index, {
back: normalizeTextMathToLatex(event.target.value),
})
}
/>
</label>
</div>
Expand Down Expand Up @@ -409,6 +398,7 @@ export function FlashcardsClient({
return;
}

const normalizedDeck = normalizeDeckMath(draftDeck);
setIsSaving(true);
try {
const payload = await readJsonResponse<{ deck: FlashcardDeck }>(
Expand All @@ -417,9 +407,9 @@ export function FlashcardsClient({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "save",
title: draftDeck.title,
sourceNoteIds: draftDeck.sourceNoteIds,
cards: draftDeck.cards,
title: normalizedDeck.title,
sourceNoteIds: normalizedDeck.sourceNoteIds,
cards: normalizedDeck.cards,
}),
}),
);
Expand All @@ -444,17 +434,18 @@ export function FlashcardsClient({
return;
}

const normalizedDeck = normalizeDeckMath(editingDeck);
setIsSaving(true);
try {
const payload = await readJsonResponse<{ deck: FlashcardDeck }>(
await fetch("/api/flashcards", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
deckId: editingDeck.id,
title: editingDeck.title,
sourceNoteIds: editingDeck.sourceNoteIds,
cards: editingDeck.cards,
deckId: normalizedDeck.id,
title: normalizedDeck.title,
sourceNoteIds: normalizedDeck.sourceNoteIds,
cards: normalizedDeck.cards,
}),
}),
);
Expand Down Expand Up @@ -591,6 +582,7 @@ export function FlashcardsClient({

<button
type="button"
aria-label={`Open actions for ${deck.title}`}
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition hover:bg-surface-elevated hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
Expand Down Expand Up @@ -903,7 +895,7 @@ export function FlashcardsClient({
<Badge variant="outline">Flip</Badge>
</div>
<div className="mx-auto flex w-full max-w-3xl flex-1 items-center justify-center py-8">
<MarkdownText
<LatexMarkdown
markdown={showBack ? activeCard.back : activeCard.front}
className="text-center text-xl font-medium leading-9"
/>
Expand Down
Loading
Loading