Refactor Study Desk application structure
This commit is contained in:
parent
faaccf8a7e
commit
089439ed90
145 changed files with 8087 additions and 3412 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue