Refactor Study Desk application structure

This commit is contained in:
Elijah 2026-08-07 19:31:23 -07:00
parent faaccf8a7e
commit 089439ed90
145 changed files with 8087 additions and 3412 deletions

View file

@ -25,6 +25,8 @@ interface DeckData {
currentIndex: number;
orderJson: string;
cardResultsJson: string | null;
sessionId: string;
revision: number;
updatedAt: string;
}>;
}
@ -42,19 +44,24 @@ export default function DeckStudyPage() {
async function handleRestart() {
if (!deck) return;
// Clear progress in database for both modes
await Promise.all([
fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" })
}).catch(() => {}),
fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" })
}).catch(() => {})
]);
const responses = await Promise.all(
deck.progress.map((progress) =>
fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentType: "DECK",
contentId: deck.id,
mode: progress.mode,
sessionId: progress.sessionId,
}),
})
)
).catch(() => null);
if (!responses || responses.some((response) => !response.ok)) {
window.alert("The session could not be restarted. Your saved progress was kept.");
return;
}
// Clear locally and force remount
setDeck({ ...deck, progress: [] });
@ -75,7 +82,8 @@ export default function DeckStudyPage() {
}, [deckId, classSlug, router]);
useEffect(() => {
fetchDeck();
const timer = window.setTimeout(() => void fetchDeck(), 0);
return () => window.clearTimeout(timer);
}, [fetchDeck]);
if (loading || !deck) {

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ImportModal } from "@/components/import/ImportModal";
@ -13,7 +13,6 @@ import {
useSensor,
useSensors,
DragEndEvent,
DragOverEvent,
DragStartEvent,
DragOverlay,
useDroppable,
@ -41,6 +40,8 @@ interface DeckItem {
currentIndex: number;
orderJson: string;
cardResultsJson: string | null;
sessionId: string;
revision: number;
}>;
}
@ -50,15 +51,25 @@ interface MaterialGroup {
sortOrder: number;
}
const cache: Record<string, { decks: DeckItem[]; groups: MaterialGroup[] }> = {};
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<string, string>)
: {};
let order: string[];
let results: Record<string, string>;
try {
const parsedOrder: unknown = JSON.parse(prog.orderJson);
const parsedResults: unknown = prog.cardResultsJson
? JSON.parse(prog.cardResultsJson)
: {};
if (!Array.isArray(parsedOrder)) throw new Error("Invalid progress order");
order = parsedOrder.filter((id): id is string => typeof id === "string");
results =
typeof parsedResults === "object" && parsedResults !== null
? (parsedResults as Record<string, string>)
: {};
} catch {
return "Saved session needs repair";
}
const correctCount = Object.values(results).filter((r) => r === "correct").length;
const total = order.length;
const current = Math.min(prog.currentIndex + 1, total);
@ -70,10 +81,21 @@ interface SortableDeckCardProps {
deck: DeckItem;
onEdit: (deck: DeckItem) => void;
onDelete: (deck: DeckItem) => void;
groups: MaterialGroup[];
onMove: (deckId: string, groupId: string | null) => void;
moveDisabled?: boolean;
classSlug: string;
}
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCardProps) {
function SortableDeckCard({
deck,
onEdit,
onDelete,
groups,
onMove,
moveDisabled = false,
classSlug,
}: SortableDeckCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
const style = {
transform: CSS.Transform.toString(transform),
@ -117,10 +139,24 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar
<button
onClick={async () => {
if (!confirm("Start over from the beginning? This will clear your current progress for this deck.")) return;
await Promise.all([
fetch("/api/progress", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" }) }).catch(() => {}),
fetch("/api/progress", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" }) }).catch(() => {})
]);
const responses = await Promise.all(
deck.progress.map((progress) =>
fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentType: "DECK",
contentId: deck.id,
mode: progress.mode,
sessionId: progress.sessionId,
}),
})
)
).catch(() => null);
if (!responses || responses.some((response) => !response.ok)) {
window.alert("The deck could not be restarted. Your saved progress was kept.");
return;
}
window.location.href = `/${classSlug}/flashcards/${deck.id}`;
}}
className="flex-1 text-center py-2 px-4 rounded-lg bg-bg-surface-alt border border-border text-text-heading text-sm font-medium hover:bg-border transition-all duration-200 cursor-pointer shadow-sm"
@ -136,6 +172,20 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar
)}
</div>
<div className="flex items-center justify-end gap-2">
<label className="sr-only" htmlFor={`move-deck-${deck.id}`}>Move {deck.name} to group</label>
<select
id={`move-deck-${deck.id}`}
value={deck.groupId ?? ""}
onChange={(event) => onMove(deck.id, event.target.value || null)}
disabled={moveDisabled}
className="min-h-9 max-w-36 rounded-lg border border-border bg-bg-surface-alt px-2 text-xs text-text-heading disabled:opacity-50"
aria-label={`Move ${deck.name} to group`}
>
<option value="">Uncategorized</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
<button onClick={() => onEdit(deck)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
@ -172,10 +222,12 @@ function DroppableContainer({ id, children }: { id: string; children: React.Reac
export default function FlashcardsPage() {
const params = useParams();
const classSlug = params.classSlug as string;
const [decks, setDecks] = useState<DeckItem[]>(cache[classSlug]?.decks || []);
const [groups, setGroups] = useState<MaterialGroup[]>(cache[classSlug]?.groups || []);
const [loading, setLoading] = useState(!cache[classSlug]);
const [animate] = useState(!cache[classSlug]);
const [decks, setDecks] = useState<DeckItem[]>([]);
const [groups, setGroups] = useState<MaterialGroup[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const requestGeneration = useRef(0);
const [showImport, setShowImport] = useState(false);
const [classId, setClassId] = useState<string>("");
const [editingId, setEditingId] = useState<string | null>(null);
@ -185,13 +237,17 @@ export default function FlashcardsPage() {
const [newGroupName, setNewGroupName] = useState("");
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editGroupName, setEditGroupName] = useState("");
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
useEffect(() => {
const saved = localStorage.getItem('flashcards_collapsed_groups');
if (saved) {
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {}
}
const timer = window.setTimeout(() => {
const saved = localStorage.getItem('flashcards_collapsed_groups');
if (saved) {
try { setCollapsedGroups(JSON.parse(saved)); } catch {}
}
}, 0);
return () => window.clearTimeout(timer);
}, []);
function toggleGroup(id: string) {
@ -202,43 +258,54 @@ export default function FlashcardsPage() {
});
}
const fetchAll = useCallback(async (cId: string) => {
const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => {
try {
const [deckRes, groupRes] = await Promise.all([
fetch(`/api/decks/list?classId=${cId}`),
fetch(`/api/material-groups?classId=${cId}&type=DECK`),
fetch(`/api/decks/list?classId=${cId}`, { signal }),
fetch(`/api/material-groups?classId=${cId}&type=DECK`, { signal }),
]);
const ds = deckRes.ok ? await deckRes.json() : [];
const gs = groupRes.ok ? await groupRes.json() : [];
if (!deckRes.ok || !groupRes.ok) throw new Error("Unable to load flashcard library");
const ds = await deckRes.json();
const gs = await groupRes.json();
if (generation !== requestGeneration.current) return;
setDecks(ds);
setGroups(gs);
cache[classSlug] = { decks: ds, groups: gs };
} catch {
setDecks([]);
setGroups([]);
setLoadError(null);
} catch (error) {
if (signal?.aborted || generation !== requestGeneration.current) return;
setLoadError(error instanceof Error ? error.message : "Unable to load flashcard library");
} finally {
setLoading(false);
if (generation === requestGeneration.current) setLoading(false);
}
}, [classSlug]);
}, []);
useEffect(() => {
const controller = new AbortController();
const generation = ++requestGeneration.current;
async function init() {
try {
const classRes = await fetch("/api/classes");
setLoading(true);
setLoadError(null);
const classRes = await fetch("/api/classes", { signal: controller.signal });
if (!classRes.ok) throw new Error("Unable to load class");
const classes = await classRes.json();
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
if (!cls) {
setLoading(false);
return;
}
if (!cls) throw new Error("Class not found");
if (generation !== requestGeneration.current) return;
setClassId(cls.id);
fetchAll(cls.id);
} catch {
await fetchAll(cls.id, generation, controller.signal);
} catch (error) {
if (controller.signal.aborted || generation !== requestGeneration.current) return;
setLoadError(error instanceof Error ? error.message : "Unable to load flashcard library");
setLoading(false);
}
}
init();
}, [classSlug, fetchAll]);
const timer = window.setTimeout(() => void init(), 0);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [classSlug, fetchAll, reloadKey]);
// Group Management
async function handleCreateGroup() {
@ -253,45 +320,68 @@ export default function FlashcardsPage() {
setGroups((prev) => [g, ...prev]);
setIsCreatingGroup(false);
setNewGroupName("");
setActionMessage(`Created group ${g.name}.`);
} else {
setActionMessage("The group could not be created. Please retry.");
}
}
async function handleRenameGroup(id: string) {
if (!editGroupName.trim()) return;
await fetch(`/api/material-groups/${id}`, {
const response = await fetch(`/api/material-groups/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editGroupName.trim() }),
});
if (!response.ok) {
setActionMessage("The group could not be renamed. No changes were applied.");
return;
}
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
setEditingGroupId(null);
setActionMessage("Group renamed.");
}
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)));
const response = await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
if (!response.ok) {
setActionMessage("The group could not be deleted. No changes were applied.");
return;
}
await fetchAll(classId);
setActionMessage(`Deleted group ${name}; its decks are now Uncategorized.`);
}
// 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" });
const response = await fetch(`/api/decks/${deck.id}`, { method: "DELETE" });
if (!response.ok) {
setActionMessage("The deck could not be deleted. No changes were applied.");
return;
}
setDecks((prev) => prev.filter((d) => d.id !== deck.id));
window.dispatchEvent(new Event("study-decks-changed"));
setActionMessage(`Deleted deck ${deck.name}.`);
}
async function handleRenameDeck(id: string) {
if (!editName.trim()) return;
await fetch(`/api/decks/${id}`, {
const response = await fetch(`/api/decks/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }),
});
if (!response.ok) {
setActionMessage("The deck could not be updated. No changes were applied.");
return;
}
setDecks((prev) =>
prev.map((d) => (d.id === id ? { ...d, name: editName.trim(), description: editDescription.trim() || null } : d))
);
setEditingId(null);
setActionMessage("Deck updated.");
}
// Drag and drop setup
@ -301,15 +391,13 @@ export default function FlashcardsPage() {
);
const [activeId, setActiveId] = useState<string | null>(null);
const [isReordering, setIsReordering] = useState(false);
function handleDragStart(event: DragStartEvent) {
if (isReordering) return;
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;
@ -334,40 +422,76 @@ export default function FlashcardsPage() {
}
}
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;
const oldIndex = decks.findIndex((deck) => deck.id === activeId);
const overIndex = decks.findIndex((deck) => deck.id === overId);
let nextItems = decks.map((deck) =>
deck.id === activeId ? { ...deck, groupId: targetGroupId } : { ...deck }
);
if (overIndex >= 0 && overIndex !== oldIndex) {
nextItems = arrayMove(nextItems, oldIndex, overIndex);
} else {
const [moved] = nextItems.splice(oldIndex, 1);
nextItems.push(moved);
}
await persistDeckReorder(
decks,
nextItems,
new Set([activeDeck.groupId, targetGroupId])
);
}
if (overIndex >= 0 && overIndex !== oldIndex) {
newItems = arrayMove(newItems, oldIndex, overIndex);
} else {
const oldItem = newItems.splice(oldIndex, 1)[0];
newItems.push(oldItem);
}
async function persistDeckReorder(
previousItems: DeckItem[],
proposedItems: DeckItem[],
affectedGroups: Set<string | null>
) {
const counters = new Map<string | null, number>();
const nextItems = proposedItems.map((item) => {
if (!affectedGroups.has(item.groupId)) return item;
const sortOrder = counters.get(item.groupId) ?? 0;
counters.set(item.groupId, sortOrder + 1);
return { ...item, sortOrder };
});
const updates: ReorderItem[] = nextItems
.filter((item) => affectedGroups.has(item.groupId))
.map((item) => ({ id: item.id, groupId: item.groupId }));
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
const updates: ReorderItem[] = [];
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;
setDecks(nextItems);
setIsReordering(true);
try {
const response = await fetch("/api/decks/reorder", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: updates }),
});
if (!response.ok) throw new Error("Reorder rejected");
return true;
} catch {
setDecks(previousItems);
window.alert("The deck move could not be saved. The library was refreshed.");
await fetchAll(classId);
return false;
} finally {
setIsReordering(false);
}
}
async function handleMoveDeck(deckId: string, targetGroupId: string | null) {
if (isReordering) return;
const activeDeck = decks.find((deck) => deck.id === deckId);
if (!activeDeck || activeDeck.groupId === targetGroupId) return;
const nextItems = decks
.filter((deck) => deck.id !== deckId)
.map((deck) => ({ ...deck }));
nextItems.push({ ...activeDeck, groupId: targetGroupId });
const saved = await persistDeckReorder(
decks,
nextItems,
new Set([activeDeck.groupId, targetGroupId])
);
if (saved) {
const destination = groups.find((group) => group.id === targetGroupId)?.name ?? "Uncategorized";
setActionMessage(`Moved ${activeDeck.name} to ${destination}.`);
}
}
@ -381,6 +505,11 @@ export default function FlashcardsPage() {
return (
<div className="pb-20">
{actionMessage && (
<p className="mb-4 rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status" aria-live="polite">
{actionMessage}
</p>
)}
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Collection</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Flashcard decks</h2></div>
@ -443,15 +572,23 @@ export default function FlashcardsPage() {
</div>
)}
{!loading && decks.length === 0 && groups.length === 0 && (
{!loading && loadError && (
<div role="alert" className="mb-6 rounded-2xl border border-error/30 bg-error-bg p-5 text-error">
<p className="font-bold">Flashcard library could not be loaded.</p>
<p className="mt-1 text-sm">{loadError}</p>
<button onClick={() => setReloadKey((value) => value + 1)} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
</div>
)}
{!loading && !loadError && decks.length === 0 && groups.length === 0 && (
<div className="text-center py-16">
<h3 className="text-lg font-semibold text-text-heading mb-1">No decks yet</h3>
<p className="text-text-secondary mb-4">Import your first deck or create a group</p>
</div>
)}
{!loading && (
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
{!loading && !loadError && (
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="space-y-8">
{groupedDecks.map(group => (
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
@ -485,7 +622,7 @@ export default function FlashcardsPage() {
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop decks here</div>
) : (
group.decks.map(deck => (
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} />
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} />
))
)}
</DroppableContainer>
@ -510,7 +647,7 @@ export default function FlashcardsPage() {
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized decks</div>
) : (
uncategorizedDecks.map(deck => (
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} />
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} />
))
)}
</DroppableContainer>
@ -522,7 +659,7 @@ export default function FlashcardsPage() {
<DragOverlay>
{activeDeck ? (
<div className="opacity-80 scale-105 shadow-xl rotate-2">
<SortableDeckCard deck={activeDeck} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} />
<SortableDeckCard deck={activeDeck} onEdit={()=>{}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
</div>
) : null}
</DragOverlay>

View file

@ -27,6 +27,7 @@ interface QuizAttempt {
score: number;
maxScore: number;
answersJson: string;
reviewSnapshotJson?: string | null;
isPartialRetake: boolean;
completedAt: string;
}
@ -42,6 +43,8 @@ interface QuizData {
currentIndex: number;
orderJson: string;
answersJson: string | null;
sessionId: string;
revision: number;
}>;
}
@ -60,13 +63,23 @@ export default function QuizStudyPage() {
async function handleRestart() {
if (!quiz) return;
// Clear progress in database
await fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" })
}).catch(() => {});
const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL");
if (progress) {
const response = await fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentType: "QUIZ",
contentId: quiz.id,
mode: "SEQUENTIAL",
sessionId: progress.sessionId,
}),
}).catch(() => null);
if (!response?.ok) {
window.alert("The quiz could not be restarted. Your saved progress was kept.");
return;
}
}
// Clear locally and force remount
setQuiz({ ...quiz, progress: [] });
@ -104,7 +117,8 @@ export default function QuizStudyPage() {
}, [quizId, classSlug, router]);
useEffect(() => {
fetchQuizAndAttempts();
const timer = window.setTimeout(() => void fetchQuizAndAttempts(), 0);
return () => window.clearTimeout(timer);
}, [fetchQuizAndAttempts]);
const [showTopics, setShowTopics] = useState(false);
@ -221,6 +235,7 @@ export default function QuizStudyPage() {
key={restartKey}
quiz={quiz}
retakeIds={retakeIds}
sessionKey={restartKey}
onFinished={() => {
router.push(`/${classSlug}/quizzes`);
}}

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ImportModal } from "@/components/import/ImportModal";
@ -13,7 +13,6 @@ import {
useSensor,
useSensors,
DragEndEvent,
DragOverEvent,
DragStartEvent,
DragOverlay,
useDroppable,
@ -41,6 +40,8 @@ interface QuizItem {
currentIndex: number;
orderJson: string;
answersJson: string | null;
sessionId: string;
revision: number;
}>;
}
@ -50,16 +51,25 @@ interface MaterialGroup {
sortOrder: number;
}
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
interface SortableQuizCardProps {
quiz: QuizItem;
onEdit: (quiz: QuizItem) => void;
onDelete: (quiz: QuizItem) => void;
groups: MaterialGroup[];
onMove: (quizId: string, groupId: string | null) => void;
moveDisabled?: boolean;
classSlug: string;
}
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCardProps) {
function SortableQuizCard({
quiz,
onEdit,
onDelete,
groups,
onMove,
moveDisabled = false,
classSlug,
}: SortableQuizCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
const style = {
transform: CSS.Transform.toString(transform),
@ -103,11 +113,22 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCar
<button
onClick={async () => {
if (!confirm("Start over from the beginning? This will clear your current progress for this quiz.")) return;
await fetch("/api/progress", {
const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL");
if (!progress) return;
const response = await fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" }),
}).catch(() => {});
body: JSON.stringify({
contentType: "QUIZ",
contentId: quiz.id,
mode: "SEQUENTIAL",
sessionId: progress.sessionId,
}),
}).catch(() => null);
if (!response?.ok) {
window.alert("The quiz could not be restarted. Your saved progress was kept.");
return;
}
window.location.href = `/${classSlug}/quizzes/${quiz.id}`;
}}
className="flex-1 cursor-pointer rounded-lg border border-border bg-bg-surface-alt px-4 py-2 text-center text-sm font-medium text-text-heading shadow-sm transition-all duration-200 hover:bg-border"
@ -123,6 +144,20 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCar
)}
</div>
<div className="flex items-center justify-end gap-2">
<label className="sr-only" htmlFor={`move-quiz-${quiz.id}`}>Move {quiz.name} to group</label>
<select
id={`move-quiz-${quiz.id}`}
value={quiz.groupId ?? ""}
onChange={(event) => onMove(quiz.id, event.target.value || null)}
disabled={moveDisabled}
className="min-h-9 max-w-36 rounded-lg border border-border bg-bg-surface-alt px-2 text-xs text-text-heading disabled:opacity-50"
aria-label={`Move ${quiz.name} to group`}
>
<option value="">Uncategorized</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
<button onClick={() => onEdit(quiz)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
@ -159,10 +194,12 @@ function DroppableContainer({ id, children }: { id: string; children: React.Reac
export default function QuizzesPage() {
const params = useParams();
const classSlug = params.classSlug as string;
const [quizzes, setQuizzes] = useState<QuizItem[]>(cache[classSlug]?.quizzes || []);
const [groups, setGroups] = useState<MaterialGroup[]>(cache[classSlug]?.groups || []);
const [loading, setLoading] = useState(!cache[classSlug]);
const [animate] = useState(!cache[classSlug]);
const [quizzes, setQuizzes] = useState<QuizItem[]>([]);
const [groups, setGroups] = useState<MaterialGroup[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const requestGeneration = useRef(0);
const [showImport, setShowImport] = useState(false);
const [classId, setClassId] = useState<string>("");
const [editingId, setEditingId] = useState<string | null>(null);
@ -172,13 +209,17 @@ export default function QuizzesPage() {
const [newGroupName, setNewGroupName] = useState("");
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editGroupName, setEditGroupName] = useState("");
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
useEffect(() => {
const saved = localStorage.getItem('quizzes_collapsed_groups');
if (saved) {
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {}
}
const timer = window.setTimeout(() => {
const saved = localStorage.getItem('quizzes_collapsed_groups');
if (saved) {
try { setCollapsedGroups(JSON.parse(saved)); } catch {}
}
}, 0);
return () => window.clearTimeout(timer);
}, []);
function toggleGroup(id: string) {
@ -189,43 +230,54 @@ export default function QuizzesPage() {
});
}
const fetchAll = useCallback(async (cId: string) => {
const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => {
try {
const [quizRes, groupRes] = await Promise.all([
fetch(`/api/quizzes/list?classId=${cId}`),
fetch(`/api/material-groups?classId=${cId}&type=QUIZ`),
fetch(`/api/quizzes/list?classId=${cId}`, { signal }),
fetch(`/api/material-groups?classId=${cId}&type=QUIZ`, { signal }),
]);
const qs = quizRes.ok ? await quizRes.json() : [];
const gs = groupRes.ok ? await groupRes.json() : [];
if (!quizRes.ok || !groupRes.ok) throw new Error("Unable to load quiz library");
const qs = await quizRes.json();
const gs = await groupRes.json();
if (generation !== requestGeneration.current) return;
setQuizzes(qs);
setGroups(gs);
cache[classSlug] = { quizzes: qs, groups: gs };
} catch {
setQuizzes([]);
setGroups([]);
setLoadError(null);
} catch (error) {
if (signal?.aborted || generation !== requestGeneration.current) return;
setLoadError(error instanceof Error ? error.message : "Unable to load quiz library");
} finally {
setLoading(false);
if (generation === requestGeneration.current) setLoading(false);
}
}, [classSlug]);
}, []);
useEffect(() => {
const controller = new AbortController();
const generation = ++requestGeneration.current;
async function init() {
try {
const classRes = await fetch("/api/classes");
setLoading(true);
setLoadError(null);
const classRes = await fetch("/api/classes", { signal: controller.signal });
if (!classRes.ok) throw new Error("Unable to load class");
const classes = await classRes.json();
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
if (!cls) {
setLoading(false);
return;
}
if (!cls) throw new Error("Class not found");
if (generation !== requestGeneration.current) return;
setClassId(cls.id);
fetchAll(cls.id);
} catch {
await fetchAll(cls.id, generation, controller.signal);
} catch (error) {
if (controller.signal.aborted || generation !== requestGeneration.current) return;
setLoadError(error instanceof Error ? error.message : "Unable to load quiz library");
setLoading(false);
}
}
init();
}, [classSlug, fetchAll]);
const timer = window.setTimeout(() => void init(), 0);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [classSlug, fetchAll, reloadKey]);
// Group Management
async function handleCreateGroup() {
@ -240,45 +292,67 @@ export default function QuizzesPage() {
setGroups((prev) => [g, ...prev]);
setIsCreatingGroup(false);
setNewGroupName("");
setActionMessage(`Created group ${g.name}.`);
} else {
setActionMessage("The group could not be created. Please retry.");
}
}
async function handleRenameGroup(id: string) {
if (!editGroupName.trim()) return;
await fetch(`/api/material-groups/${id}`, {
const response = await fetch(`/api/material-groups/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editGroupName.trim() }),
});
if (!response.ok) {
setActionMessage("The group could not be renamed. No changes were applied.");
return;
}
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
setEditingGroupId(null);
setActionMessage("Group renamed.");
}
async function handleDeleteGroup(id: string, name: string) {
if (!confirm(`Delete group "${name}"? Quizzes inside will be moved to Uncategorized.`)) return;
await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
setGroups((prev) => prev.filter((g) => g.id !== id));
setQuizzes((prev) => prev.map((q) => (q.groupId === id ? { ...q, groupId: null } : q)));
const response = await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
if (!response.ok) {
setActionMessage("The group could not be deleted. No changes were applied.");
return;
}
await fetchAll(classId);
setActionMessage(`Deleted group ${name}; its quizzes are now Uncategorized.`);
}
// Quiz Management
async function handleDeleteQuiz(quiz: QuizItem) {
if (!confirm(`Delete "${quiz.name}" and all its questions and attempts?`)) return;
await fetch(`/api/quizzes/${quiz.id}`, { method: "DELETE" });
const response = await fetch(`/api/quizzes/${quiz.id}`, { method: "DELETE" });
if (!response.ok) {
setActionMessage("The quiz could not be deleted. No changes were applied.");
return;
}
setQuizzes((prev) => prev.filter((q) => q.id !== quiz.id));
setActionMessage(`Deleted quiz ${quiz.name}.`);
}
async function handleRenameQuiz(id: string) {
if (!editName.trim()) return;
await fetch(`/api/quizzes/${id}`, {
const response = await fetch(`/api/quizzes/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }),
});
if (!response.ok) {
setActionMessage("The quiz could not be updated. No changes were applied.");
return;
}
setQuizzes((prev) =>
prev.map((q) => (q.id === id ? { ...q, name: editName.trim(), description: editDescription.trim() || null } : q))
);
setEditingId(null);
setActionMessage("Quiz updated.");
}
// Drag and drop setup
@ -288,26 +362,13 @@ export default function QuizzesPage() {
);
const [activeId, setActiveId] = useState<string | null>(null);
const [isReordering, setIsReordering] = useState(false);
function handleDragStart(event: DragStartEvent) {
if (isReordering) return;
setActiveId(event.active.id as string);
}
function handleDragOver(event: DragOverEvent) {
const { active, over } = event;
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
if (activeId === overId) return;
// Is it dropping over a container directly?
const isOverContainer = over.data.current?.sortable?.containerId || overId;
// ... we handle layout updates in dragEnd to keep it simple, or here for smooth lists.
// For simplicity, we just handle the final move in dragEnd.
}
async function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
@ -333,43 +394,76 @@ export default function QuizzesPage() {
}
}
if (targetGroupId !== undefined) {
setQuizzes((items) => {
const oldIndex = items.findIndex((q) => q.id === activeId);
const overIndex = items.findIndex((q) => q.id === overId);
let newItems = [...items];
newItems[oldIndex].groupId = targetGroupId;
const oldIndex = quizzes.findIndex((quiz) => quiz.id === activeId);
const overIndex = quizzes.findIndex((quiz) => quiz.id === overId);
let nextItems = quizzes.map((quiz) =>
quiz.id === activeId ? { ...quiz, groupId: targetGroupId } : { ...quiz }
);
if (overIndex >= 0 && overIndex !== oldIndex) {
nextItems = arrayMove(nextItems, oldIndex, overIndex);
} else {
const [moved] = nextItems.splice(oldIndex, 1);
nextItems.push(moved);
}
await persistQuizReorder(
quizzes,
nextItems,
new Set([activeQuiz.groupId, targetGroupId])
);
}
if (overIndex >= 0 && overIndex !== oldIndex) {
newItems = arrayMove(newItems, oldIndex, overIndex);
} else {
// just moved to end of a group
const oldItem = newItems.splice(oldIndex, 1)[0];
newItems.push(oldItem);
}
async function persistQuizReorder(
previousItems: QuizItem[],
proposedItems: QuizItem[],
affectedGroups: Set<string | null>
) {
const counters = new Map<string | null, number>();
const nextItems = proposedItems.map((item) => {
if (!affectedGroups.has(item.groupId)) return item;
const sortOrder = counters.get(item.groupId) ?? 0;
counters.set(item.groupId, sortOrder + 1);
return { ...item, sortOrder };
});
const updates: ReorderItem[] = nextItems
.filter((item) => affectedGroups.has(item.groupId))
.map((item) => ({ id: item.id, groupId: item.groupId }));
// Re-calculate sortOrder for the affected groups to persist
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
const updates: ReorderItem[] = [];
affectedGroups.forEach(gId => {
const gItems = newItems.filter(q => q.groupId === gId);
gItems.forEach((item, index) => {
item.sortOrder = index;
updates.push({ id: item.id, sortOrder: index, groupId: item.groupId });
});
});
// Fire API
fetch("/api/quizzes/reorder", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: updates }),
});
return newItems;
setQuizzes(nextItems);
setIsReordering(true);
try {
const response = await fetch("/api/quizzes/reorder", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: updates }),
});
if (!response.ok) throw new Error("Reorder rejected");
return true;
} catch {
setQuizzes(previousItems);
window.alert("The quiz move could not be saved. The library was refreshed.");
await fetchAll(classId);
return false;
} finally {
setIsReordering(false);
}
}
async function handleMoveQuiz(quizId: string, targetGroupId: string | null) {
if (isReordering) return;
const activeQuiz = quizzes.find((quiz) => quiz.id === quizId);
if (!activeQuiz || activeQuiz.groupId === targetGroupId) return;
const nextItems = quizzes
.filter((quiz) => quiz.id !== quizId)
.map((quiz) => ({ ...quiz }));
nextItems.push({ ...activeQuiz, groupId: targetGroupId });
const saved = await persistQuizReorder(
quizzes,
nextItems,
new Set([activeQuiz.groupId, targetGroupId])
);
if (saved) {
const destination = groups.find((group) => group.id === targetGroupId)?.name ?? "Uncategorized";
setActionMessage(`Moved ${activeQuiz.name} to ${destination}.`);
}
}
@ -384,6 +478,11 @@ export default function QuizzesPage() {
return (
<div className="pb-20">
{actionMessage && (
<p className="mb-4 rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status" aria-live="polite">
{actionMessage}
</p>
)}
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Practice</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Practice quizzes</h2></div>
@ -447,15 +546,23 @@ export default function QuizzesPage() {
</div>
)}
{!loading && quizzes.length === 0 && groups.length === 0 && (
{!loading && loadError && (
<div role="alert" className="mb-6 rounded-2xl border border-error/30 bg-error-bg p-5 text-error">
<p className="font-bold">Quiz library could not be loaded.</p>
<p className="mt-1 text-sm">{loadError}</p>
<button onClick={() => setReloadKey((value) => value + 1)} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
</div>
)}
{!loading && !loadError && quizzes.length === 0 && groups.length === 0 && (
<div className="text-center py-16">
<h3 className="text-lg font-semibold text-text-heading mb-1">No quizzes yet</h3>
<p className="text-text-secondary mb-4">Import your first quiz or create a group</p>
</div>
)}
{!loading && (
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
{!loading && !loadError && (
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="space-y-8">
{groupedQuizzes.map(group => (
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
@ -476,7 +583,7 @@ export default function QuizzesPage() {
)}
<div className="flex items-center gap-2">
<ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} />
<ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} containsQuizAnswers />
<button onClick={() => { setEditingGroupId(group.id); setEditGroupName(group.name); }} className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg></button>
<button onClick={() => handleDeleteGroup(group.id, group.name)} className="p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-bg-surface transition-colors cursor-pointer"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg></button>
</div>
@ -489,7 +596,7 @@ export default function QuizzesPage() {
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop quizzes here</div>
) : (
group.quizzes.map(quiz => (
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} />
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} />
))
)}
</DroppableContainer>
@ -514,7 +621,7 @@ export default function QuizzesPage() {
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized quizzes</div>
) : (
uncategorizedQuizzes.map(quiz => (
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} />
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} />
))
)}
</DroppableContainer>
@ -526,7 +633,7 @@ export default function QuizzesPage() {
<DragOverlay>
{activeQuiz ? (
<div className="opacity-80 scale-105 shadow-xl rotate-2">
<SortableQuizCard quiz={activeQuiz} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} />
<SortableQuizCard quiz={activeQuiz} onEdit={()=>{}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
</div>
) : null}
</DragOverlay>