From 7af0935657bb8653fbacf8108ed8b4ad7faa4a35 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 10 Jul 2026 16:01:22 -0700 Subject: [PATCH] Implement local browser caching for shared groups and UI improvements --- dev.db | Bin 139264 -> 155648 bytes out.css | 1 + package-lock.json | 57 + package.json | 3 + prisma/schema.prisma | 31 +- .../[classSlug]/flashcards/page.tsx | 604 +++-- .../(protected)/[classSlug]/quizzes/page.tsx | 539 +++-- src/app/api/decks/reorder/route.ts | 31 + src/app/api/decks/route.ts | 5 +- src/app/api/material-groups/[id]/route.ts | 41 + src/app/api/material-groups/route.ts | 56 + src/app/api/quizzes/reorder/route.ts | 31 + src/app/api/quizzes/route.ts | 5 +- src/app/api/share/route.ts | 20 +- .../[type]/[token]/SharedGroupViewer.tsx | 133 ++ .../[type]/[token]/SharedViewer.tsx | 39 +- .../[classSlug]/[type]/[token]/page.tsx | 45 +- src/components/flashcards/FlashcardViewer.tsx | 38 +- src/components/import/ImportTab.tsx | 45 +- src/components/quizzes/QuizViewer.tsx | 59 +- src/components/ui/ShareMenu.tsx | 29 +- src/generated/prisma/browser.ts | 5 + src/generated/prisma/client.ts | 5 + src/generated/prisma/internal/class.ts | 18 +- .../prisma/internal/prismaNamespace.ts | 99 +- .../prisma/internal/prismaNamespaceBrowser.ts | 22 +- src/generated/prisma/models.ts | 1 + src/generated/prisma/models/Class.ts | 122 + src/generated/prisma/models/Deck.ts | 211 +- src/generated/prisma/models/MaterialGroup.ts | 1806 +++++++++++++++ src/generated/prisma/models/QuizSet.ts | 219 +- src/generated/prisma/models/ShareLink.ts | 149 +- src/services/deckService.ts | 6 +- src/services/quizService.ts | 6 +- src/services/shareService.ts | 53 +- temp.css | 2017 +++++++++++++++++ test.css | 1 + 37 files changed, 6141 insertions(+), 411 deletions(-) create mode 100644 out.css create mode 100644 src/app/api/decks/reorder/route.ts create mode 100644 src/app/api/material-groups/[id]/route.ts create mode 100644 src/app/api/material-groups/route.ts create mode 100644 src/app/api/quizzes/reorder/route.ts create mode 100644 src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx create mode 100644 src/generated/prisma/models/MaterialGroup.ts create mode 100644 temp.css create mode 100644 test.css diff --git a/dev.db b/dev.db index f2d238b323de6b080c40a60aa34849c46efd563e..2d75c1547249665ea4693d62e468bb341c790859 100644 GIT binary patch delta 2421 zcmb_ee{2&~9KYA=`upx}Y^8$%ty@Q#tZS6o2YpdeW^ zqY4B87shEWF-A~hBw!q)(MtS7OeDrXLVnR*4;VSMk*(J$STyIZG>eM@a_0?D2poJ_O<{|=1WpgY`@r<0KA{j13<)VB> z2#0uHjz)@)0kqIvTx3HF`%g{Qv_>s>~rAc84QlY7V->Y{SrAgf?FjIhDpghuVgYZ8=-iXXDB|WaFm=u-FzgR z;i4ffC*+mnz(#3PEiSP%D^PTpV(2u>#zJh2;e!IlchYo>rbkROkc^TYVD5y|8zei6;_R7OEJMi$Waurf=BGyPJ}X0#Lj&T5-% zIL6b#Fw0bI>e0sG;Yzd*&qjN!mO$bwa0wvy@9VuOfJ^pyod1)$M7EfP5og$i7?^K=~Og%2|R(gkaZXaZ- zy3T`vR;#C_1srKF$eA5V|K6d|lv0>^Sk#-=cb5*YuzJE?Q1lG#%qjclEN$DpYwxi3 zNCK>?tGpghfM%!h+LLST*cZ?~?gA@;M=N%Nh1ysG*tNz4c(C;l=C^vhUhv#T1M;51 zdoKg-h68njSt7EioIi}!#foyfOAX2v_~eSQCfY@G%1rNxKS>Q&p*I+X>)jEO;G`aiH~ zY)y%2-v+6-*SB$lPukQSPm8`pJe7!di;j-K0~XI}x4voW+b+kVQ=Jb_13QXoxjbe*5n#Bs5$g6{%2lM@9bg9RE2ktsp|Tl4J@;I0s(NCF!)wYR(0AE z1ETJEI^NYQ&f7EX>Ut1Vb521Wu`E`TL`J)tL=mrAul{Juo8hOJsD07};-)8-Yikmq z1D&$jqefX6^aF@qJ(k+C9?%4w<+l>x5U^0q`e`u6FN>f-V??m8d|d=xz=D1sZf=R& z5O^Hk!r&eFJG@nH_!@izmzZ%3;QGyk?y=4PG&Fz$Hk|eWA;Vu52gIo8L4K zgJA~_&+69B!fQytUe?n^z7~Nu1gsWSHaN}5fd25JhVl)z5W#%}%hP{@6+V|PUMARt z!K1JVr6n7r3MfZNZnrI23x+zi#y^l&_7u9s#QJ%X&BQ`XFf0fa$u6KYV3@7NN8PMf zH>;7`cuH?=g=pA#{?Tze{fpnHcSD+_{N)zf9=K-#j}A+CcUE^*WYFQyVnxitA{XkEzkq1knAIwcWtbrJa zuYcZGqsvH%d_(Fk33}c(>mQq|)nF91uozv2L)I9B{y-BwK_cH#?!th-q4ou@qLo-K z)|p?Ghfy!yMr233s42>lGA-BTbJ0E-N!iG5Bo}!mei9wwrEnf^bK{RNo*!U`MHphw zOORsiBFL;X4yW<$P+4! z*fTSRJ!@IEO-;+no73*)tDOV~O=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", diff --git a/package.json b/package.json index f680e08..6ee3ab5 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "lint": "eslint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@prisma/adapter-better-sqlite3": "^7.8.0", "@prisma/client": "^7.8.0", "argon2": "^0.44.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8f51528..dc8a941 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,8 +14,9 @@ model Class { sortOrder Int @default(0) createdAt DateTime @default(now()) - decks Deck[] - quizSets QuizSet[] + decks Deck[] + quizSets QuizSet[] + materialGroups MaterialGroup[] } model Deck { @@ -27,6 +28,8 @@ model Deck { createdAt DateTime @default(now()) class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + groupId String? + group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) cards Flashcard[] shareLink ShareLink? progress StudyProgress[] @@ -51,6 +54,8 @@ model QuizSet { createdAt DateTime @default(now()) class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + groupId String? + group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) questions Question[] attempts QuizAttempt[] shareLink ShareLink? @@ -113,13 +118,15 @@ model QuizAttempt { model ShareLink { id String @id @default(uuid()) - targetType String // "DECK" | "QUIZ" + targetType String // "DECK" | "QUIZ" | "GROUP" deckId String? @unique quizSetId String? @unique + groupId String? @unique createdAt DateTime @default(now()) - deck Deck? @relation(fields: [deckId], references: [id], onDelete: Cascade) - quizSet QuizSet? @relation(fields: [quizSetId], references: [id], onDelete: Cascade) + deck Deck? @relation(fields: [deckId], references: [id], onDelete: Cascade) + quizSet QuizSet? @relation(fields: [quizSetId], references: [id], onDelete: Cascade) + group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: Cascade) } model AuthSecurity { @@ -133,3 +140,17 @@ model Setting { key String @id value String } + +model MaterialGroup { + id String @id @default(uuid()) + classId String + name String + type String // "DECK" | "QUIZ" + sortOrder Int @default(0) + createdAt DateTime @default(now()) + + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + decks Deck[] + quizSets QuizSet[] + shareLink ShareLink? +} diff --git a/src/app/(protected)/[classSlug]/flashcards/page.tsx b/src/app/(protected)/[classSlug]/flashcards/page.tsx index 9c773b9..ce0f56a 100644 --- a/src/app/(protected)/[classSlug]/flashcards/page.tsx +++ b/src/app/(protected)/[classSlug]/flashcards/page.tsx @@ -4,11 +4,36 @@ import { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ImportModal } from "@/components/import/ImportModal"; +import { ShareMenu } from "@/components/ui/ShareMenu"; +import { + DndContext, + pointerWithin, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, + DragOverEvent, + DragStartEvent, + DragOverlay, + useDroppable, + useDndContext, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; interface DeckItem { id: string; name: string; description: string | null; + groupId: string | null; + sortOrder: number; _count: { cards: number }; progress: Array<{ mode: string; @@ -18,12 +43,126 @@ interface DeckItem { }>; } -const cache: Record = {}; +interface MaterialGroup { + id: string; + name: string; + sortOrder: number; +} + +const cache: Record = {}; + +function getProgressLabel(deck: DeckItem) { + if (!deck.progress?.length) return null; + const prog = deck.progress[0]; + const order = JSON.parse(prog.orderJson) as string[]; + const results = prog.cardResultsJson + ? (JSON.parse(prog.cardResultsJson) as Record) + : {}; + const correctCount = Object.values(results).filter((r) => r === "correct").length; + const total = order.length; + const current = Math.min(prog.currentIndex + 1, total); + const modeLabel = prog.mode === "SHUFFLED" ? "shuffled" : "sequential"; + return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`; +} + +function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + }; + + const progressLabel = getProgressLabel(deck); + + return ( +
+
+
+
+ + + +
+
+

{deck.name}

+ {deck.description &&

{deck.description}

} +
+
+ +
+ + {deck._count.cards} {deck._count.cards === 1 ? "card" : "cards"} + + {progressLabel && ( + {progressLabel} + )} +
+ +
+ {deck.progress?.length ? ( + <> + + Continue + + + + ) : ( + + Study + + )} + + +
+
+
+ ); +} + +function DroppableContainer({ id, children }: { id: string; children: React.ReactNode }) { + const { setNodeRef } = useDroppable({ id }); + const { over } = useDndContext(); + + const isOverContainer = over?.id === id; + const isOverChild = over?.data?.current?.sortable?.containerId === id; + const isOver = isOverContainer || isOverChild; + + return ( +
+ {children} +
+ ); +} export default function FlashcardsPage() { const params = useParams(); const classSlug = params.classSlug as string; - const [decks, setDecks] = useState(cache[classSlug] || []); + const [decks, setDecks] = useState(cache[classSlug]?.decks || []); + const [groups, setGroups] = useState(cache[classSlug]?.groups || []); const [loading, setLoading] = useState(!cache[classSlug]); const [animate] = useState(!cache[classSlug]); const [showImport, setShowImport] = useState(false); @@ -31,22 +170,29 @@ export default function FlashcardsPage() { const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); const [editDescription, setEditDescription] = useState(""); + const [isCreatingGroup, setIsCreatingGroup] = useState(false); + const [newGroupName, setNewGroupName] = useState(""); + const [editingGroupId, setEditingGroupId] = useState(null); + const [editGroupName, setEditGroupName] = useState(""); - const fetchDecks = useCallback(async (cId: string) => { + const fetchAll = useCallback(async (cId: string) => { try { - const res = await fetch(`/api/decks/list?classId=${cId}`); - if (res.ok) { - const data = await res.json(); - const arr = Array.isArray(data) ? data : []; - setDecks(arr); - cache[classSlug] = arr; - } + const [deckRes, groupRes] = await Promise.all([ + fetch(`/api/decks/list?classId=${cId}`), + fetch(`/api/material-groups?classId=${cId}&type=DECK`), + ]); + const ds = deckRes.ok ? await deckRes.json() : []; + const gs = groupRes.ok ? await groupRes.json() : []; + setDecks(ds); + setGroups(gs); + cache[classSlug] = { decks: ds, groups: gs }; } catch { setDecks([]); + setGroups([]); } finally { setLoading(false); } - }, []); + }, [classSlug]); useEffect(() => { async function init() { @@ -59,21 +205,56 @@ export default function FlashcardsPage() { return; } setClassId(cls.id); - fetchDecks(cls.id); + fetchAll(cls.id); } catch { setLoading(false); } } init(); - }, [classSlug, fetchDecks]); + }, [classSlug, fetchAll]); - async function handleDelete(id: string, name: string) { - if (!confirm(`Delete "${name}" and all its cards?`)) return; - await fetch(`/api/decks/${id}`, { method: "DELETE" }); - setDecks((prev) => prev.filter((d) => d.id !== id)); + // Group Management + async function handleCreateGroup() { + if (!newGroupName.trim()) return; + const res = await fetch("/api/material-groups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ classId, name: newGroupName.trim(), type: "DECK" }), + }); + if (res.ok) { + const g = await res.json(); + setGroups((prev) => [...prev, g]); + setIsCreatingGroup(false); + setNewGroupName(""); + } } - async function handleRename(id: string) { + async function handleRenameGroup(id: string) { + if (!editGroupName.trim()) return; + await fetch(`/api/material-groups/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: editGroupName.trim() }), + }); + setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g))); + setEditingGroupId(null); + } + + async function handleDeleteGroup(id: string, name: string) { + if (!confirm(`Delete group "${name}"? Decks inside will be moved to Uncategorized.`)) return; + await fetch(`/api/material-groups/${id}`, { method: "DELETE" }); + setGroups((prev) => prev.filter((g) => g.id !== id)); + setDecks((prev) => prev.map((d) => (d.groupId === id ? { ...d, groupId: null } : d))); + } + + // Deck Management + async function handleDeleteDeck(deck: DeckItem) { + if (!confirm(`Delete "${deck.name}" and all its cards?`)) return; + await fetch(`/api/decks/${deck.id}`, { method: "DELETE" }); + setDecks((prev) => prev.filter((d) => d.id !== deck.id)); + } + + async function handleRenameDeck(id: string) { if (!editName.trim()) return; await fetch(`/api/decks/${id}`, { method: "PATCH", @@ -86,201 +267,230 @@ export default function FlashcardsPage() { setEditingId(null); } - function getProgressLabel(deck: DeckItem) { - if (!deck.progress?.length) return null; - const prog = deck.progress[0]; - const order = JSON.parse(prog.orderJson) as string[]; - const results = prog.cardResultsJson - ? (JSON.parse(prog.cardResultsJson) as Record) - : {}; - const correctCount = Object.values(results).filter((r) => r === "correct").length; - const total = order.length; - const current = Math.min(prog.currentIndex + 1, total); - const modeLabel = prog.mode === "SHUFFLED" ? "shuffled" : "sequential"; - return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`; + // Drag and drop setup + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + const [activeId, setActiveId] = useState(null); + + function handleDragStart(event: DragStartEvent) { + setActiveId(event.active.id as string); } + function handleDragOver(event: DragOverEvent) { + // optional layout updates here + } + + async function handleDragEnd(event: DragEndEvent) { + setActiveId(null); + const { active, over } = event; + if (!over) return; + + const activeId = active.id as string; + const overId = over.id as string; + + const activeDeck = decks.find((d) => d.id === activeId); + if (!activeDeck) return; + + let targetGroupId: string | null = null; + let targetIndex = 0; + + const overContainerId = over.data.current?.sortable?.containerId; + if (overContainerId) { + targetGroupId = overContainerId === "uncategorized" ? null : overContainerId; + } else if (groups.find(g => g.id === overId) || overId === "uncategorized") { + targetGroupId = overId === "uncategorized" ? null : overId; + } else { + const overDeck = decks.find((d) => d.id === overId); + if (overDeck) { + targetGroupId = overDeck.groupId; + } + } + + if (targetGroupId !== undefined) { + setDecks((items) => { + const oldIndex = items.findIndex((d) => d.id === activeId); + const overIndex = items.findIndex((d) => d.id === overId); + let newItems = [...items]; + + newItems[oldIndex].groupId = targetGroupId; + + if (overIndex >= 0 && overIndex !== oldIndex) { + newItems = arrayMove(newItems, oldIndex, overIndex); + } else { + const oldItem = newItems.splice(oldIndex, 1)[0]; + newItems.push(oldItem); + } + + const affectedGroups = new Set([activeDeck.groupId, targetGroupId]); + const updates: any[] = []; + + affectedGroups.forEach(gId => { + const gItems = newItems.filter(d => d.groupId === gId); + gItems.forEach((item, index) => { + item.sortOrder = index; + updates.push({ id: item.id, sortOrder: index, groupId: item.groupId }); + }); + }); + + fetch("/api/decks/reorder", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: updates }), + }); + + return newItems; + }); + } + } + + const groupedDecks = groups.map(g => ({ + ...g, + decks: decks.filter(d => d.groupId === g.id).sort((a,b) => a.sortOrder - b.sortOrder) + })); + const uncategorizedDecks = decks.filter(d => d.groupId === null).sort((a,b) => a.sortOrder - b.sortOrder); + + const activeDeck = decks.find(d => d.id === activeId); + return ( -
+
{/* Header */}

Flashcard Decks

- +
+ + +
- - - {/* Empty state */} - {!loading && decks.length === 0 && ( -
-
- - - -
-

No flashcard decks yet

-

Import your first deck to start studying

- +
)} - {/* Deck grid */} - {!loading && decks.length > 0 && ( -
- {decks.map((deck) => { - const progressLabel = getProgressLabel(deck); - - return ( -
-
- {/* Name and Description (editable) */} - {editingId === deck.id ? ( -
- setEditName(e.target.value)} - placeholder="Deck Name" - autoFocus - className="w-full px-3 py-1.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-primary/30" - /> -