From f8a3a16fd4a4d0fa47bb6df580c8686c72d2f65b Mon Sep 17 00:00:00 2001 From: Ali Date: Mon, 8 Jun 2026 09:34:34 -0500 Subject: [PATCH 1/2] Refactor notes rendering and add class archiving --- .../classes/[runId]/class-settings-panel.tsx | 316 +++++++ app/(app)/classes/[runId]/page.tsx | 550 ++++++------ app/api/classes/[runId]/archive/route.ts | 42 + app/globals.css | 170 +++- components/note-editor/README.md | 25 + .../{ => __tests__}/math-block-tool.test.ts | 2 +- .../{ => __tests__}/note-editor.test.tsx | 0 .../{ => __tests__}/note-renderer.test.tsx | 0 .../{ => __tests__}/note-surface.test.tsx | 0 .../__tests__/selection-geometry.test.ts | 35 + components/note-editor/block-clipboard.ts | 52 ++ components/note-editor/block-transforms.ts | 230 +++++ .../{ => blocks}/code-block-tool.ts | 0 .../{ => blocks}/code-block-view.tsx | 2 - .../{ => blocks}/math-block-tool.ts | 0 .../note-editor/blocks/mermaid-block-tool.ts | 283 +++++++ .../note-editor/blocks/mermaid-block-view.tsx | 87 ++ .../note-editor/blocks/mermaid-renderer.ts | 34 + components/note-editor/document-utils.ts | 5 + components/note-editor/editor-menus.tsx | 128 +++ components/note-editor/editorjs-tools.ts | 96 +++ components/note-editor/image-upload.ts | 41 + .../note-editor/markdown-components.tsx | 152 ++++ components/note-editor/note-editor.tsx | 768 ++++------------- components/note-editor/note-renderer.tsx | 117 +-- components/note-editor/note-surface.tsx | 2 +- components/note-editor/selection-geometry.ts | 37 + lib/classes/archive-marker.ts | 5 + lib/classes/archive.ts | 37 + lib/classes/queries.ts | 23 +- lib/notes/__tests__/generation.test.ts | 30 + lib/notes/{ => __tests__}/markdown.test.ts | 13 +- lib/notes/__tests__/parse-markdown.test.ts | 106 +++ lib/notes/{ => __tests__}/persistence.test.ts | 0 lib/notes/__tests__/records.test.ts | 92 ++ lib/notes/generation.ts | 97 ++- lib/notes/markdown.ts | 4 + lib/notes/parse-markdown.ts | 89 +- lib/notes/records.ts | 93 +- lib/notes/types.ts | 18 + lib/parse-test/normalize.ts | 5 + package.json | 1 + pnpm-lock.yaml | 798 ++++++++++++++++++ 43 files changed, 3551 insertions(+), 1034 deletions(-) create mode 100644 app/(app)/classes/[runId]/class-settings-panel.tsx create mode 100644 app/api/classes/[runId]/archive/route.ts create mode 100644 components/note-editor/README.md rename components/note-editor/{ => __tests__}/math-block-tool.test.ts (97%) rename components/note-editor/{ => __tests__}/note-editor.test.tsx (100%) rename components/note-editor/{ => __tests__}/note-renderer.test.tsx (100%) rename components/note-editor/{ => __tests__}/note-surface.test.tsx (100%) create mode 100644 components/note-editor/__tests__/selection-geometry.test.ts create mode 100644 components/note-editor/block-clipboard.ts create mode 100644 components/note-editor/block-transforms.ts rename components/note-editor/{ => blocks}/code-block-tool.ts (100%) rename components/note-editor/{ => blocks}/code-block-view.tsx (89%) rename components/note-editor/{ => blocks}/math-block-tool.ts (100%) create mode 100644 components/note-editor/blocks/mermaid-block-tool.ts create mode 100644 components/note-editor/blocks/mermaid-block-view.tsx create mode 100644 components/note-editor/blocks/mermaid-renderer.ts create mode 100644 components/note-editor/document-utils.ts create mode 100644 components/note-editor/editor-menus.tsx create mode 100644 components/note-editor/editorjs-tools.ts create mode 100644 components/note-editor/image-upload.ts create mode 100644 components/note-editor/markdown-components.tsx create mode 100644 components/note-editor/selection-geometry.ts create mode 100644 lib/classes/archive-marker.ts create mode 100644 lib/classes/archive.ts create mode 100644 lib/notes/__tests__/generation.test.ts rename lib/notes/{ => __tests__}/markdown.test.ts (93%) create mode 100644 lib/notes/__tests__/parse-markdown.test.ts rename lib/notes/{ => __tests__}/persistence.test.ts (100%) create mode 100644 lib/notes/__tests__/records.test.ts diff --git a/app/(app)/classes/[runId]/class-settings-panel.tsx b/app/(app)/classes/[runId]/class-settings-panel.tsx new file mode 100644 index 0000000..7c9fafd --- /dev/null +++ b/app/(app)/classes/[runId]/class-settings-panel.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useMemo, useState, useTransition, type ChangeEvent } from "react"; +import { useRouter } from "next/navigation"; +import { Archive, Loader2, Save } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input, Textarea } from "@/components/ui/input"; +import type { ParseTestViewModel } from "@/lib/parse-test/contracts"; +import { DeleteClassButton } from "./delete-class-button"; + +type ClassSettingsPanelProps = { + preview: ParseTestViewModel; +}; + +function joinList(values: string[]) { + return values.join(", "); +} + +function splitList(value: string) { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function nullableTrim(value: string) { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function createReviewPayload(preview: ParseTestViewModel, form: ClassSettingsFormState) { + return { + runId: preview.run.id, + course: { + title: form.title.trim(), + courseCode: nullableTrim(form.courseCode), + courseSection: nullableTrim(form.courseSection), + term: nullableTrim(form.term), + instructorName: nullableTrim(form.instructorName), + meetingDays: nullableTrim(form.meetingDays), + meetingTime: nullableTrim(form.meetingTime), + meetingLocation: nullableTrim(form.meetingLocation), + requiredMaterials: splitList(form.requiredMaterials), + homeworkTools: splitList(form.homeworkTools), + catalogDescription: nullableTrim(form.catalogDescription), + studentSummary: form.studentSummary.trim() || preview.course.studentSummary, + descriptionSource: preview.course.descriptionSource, + }, + concepts: preview.concepts.map((concept) => ({ + label: concept.label, + })), + contacts: preview.contacts.map((contact) => ({ + role: contact.role, + name: contact.name, + email: contact.email, + officeHours: contact.officeHours, + location: contact.location, + sourceSnippet: contact.sourceSnippet, + })), + gradingItems: preview.gradingItems.map((item) => ({ + label: item.label, + weightPercent: item.weightPercent, + sourceSnippet: item.sourceSnippet, + })), + assignments: preview.assignments.map((assignment) => ({ + title: assignment.title, + category: assignment.category, + dateText: assignment.dateText, + dueAt: assignment.dueAt, + timeText: assignment.timeText, + weightPercent: assignment.weightPercent, + sourceSnippet: assignment.sourceSnippet, + })), + events: preview.events.map((event) => ({ + title: event.title, + category: event.category, + dateText: event.dateText, + dueAt: event.dueAt, + timeText: event.timeText, + location: event.location, + sourceSnippet: event.sourceSnippet, + })), + }; +} + +type ClassSettingsFormState = { + title: string; + courseCode: string; + courseSection: string; + term: string; + instructorName: string; + meetingDays: string; + meetingTime: string; + meetingLocation: string; + requiredMaterials: string; + homeworkTools: string; + catalogDescription: string; + studentSummary: string; +}; + +function createInitialFormState(preview: ParseTestViewModel): ClassSettingsFormState { + return { + title: preview.course.title, + courseCode: preview.course.courseCode ?? "", + courseSection: preview.course.courseSection ?? "", + term: preview.course.term ?? "", + instructorName: preview.course.instructorName ?? "", + meetingDays: preview.course.meetingDays ?? "", + meetingTime: preview.course.meetingTime ?? "", + meetingLocation: preview.course.meetingLocation ?? "", + requiredMaterials: joinList(preview.course.requiredMaterials), + homeworkTools: joinList(preview.course.homeworkTools), + catalogDescription: preview.course.catalogDescription ?? "", + studentSummary: preview.course.studentSummary, + }; +} + +export function ClassSettingsPanel({ preview }: ClassSettingsPanelProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const initialFormState = useMemo(() => createInitialFormState(preview), [preview]); + const [form, setForm] = useState(initialFormState); + const [isSaving, setIsSaving] = useState(false); + const [isArchiving, setIsArchiving] = useState(false); + const isBusy = isSaving || isArchiving || isPending; + + const updateField = + (field: keyof ClassSettingsFormState) => + (event: ChangeEvent) => { + setForm((current) => ({ ...current, [field]: event.currentTarget.value })); + }; + + async function saveSettings() { + if (!form.title.trim()) { + toast.error("Class title is required"); + return; + } + + setIsSaving(true); + const toastId = toast.loading("Saving class settings...", { + duration: Infinity, + }); + + try { + const response = await fetch("/api/parse-test", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(createReviewPayload(preview, form)), + }); + const payload = (await response.json().catch(() => null)) as { + error?: string; + } | null; + + if (!response.ok) { + throw new Error(payload?.error || "Could not save class settings."); + } + + toast.success("Class settings saved", { id: toastId }); + startTransition(() => router.refresh()); + } catch (error) { + toast.error("Could not save class settings", { + id: toastId, + description: error instanceof Error ? error.message : undefined, + duration: 5000, + }); + } finally { + setIsSaving(false); + } + } + + async function archiveClass() { + setIsArchiving(true); + const toastId = toast.loading("Archiving class...", { + duration: Infinity, + }); + + try { + const response = await fetch(`/api/classes/${preview.run.id}/archive`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ archived: true }), + }); + const payload = (await response.json().catch(() => null)) as { + error?: string; + } | null; + + if (!response.ok) { + throw new Error(payload?.error || "Could not archive class."); + } + + toast.success("Class archived", { id: toastId }); + startTransition(() => { + router.replace("/classes"); + router.refresh(); + }); + } catch (error) { + toast.error("Could not archive class", { + id: toastId, + description: error instanceof Error ? error.message : undefined, + duration: 5000, + }); + } finally { + setIsArchiving(false); + } + } + + return ( +
+
+

Class settings

+

+ Edit the saved class details used across notes and study tools. +

+
+ +
+ +
+ + +
+ + +
+ + +
+ + + +