Implement local browser caching for shared groups and UI improvements
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m10s

This commit is contained in:
Elijah 2026-07-10 16:01:22 -07:00
parent 42ed0d275b
commit 7af0935657
37 changed files with 6141 additions and 411 deletions

View file

@ -4,11 +4,36 @@ import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ImportModal } from "@/components/import/ImportModal";
import { ShareMenu } from "@/components/ui/ShareMenu";
import {
DndContext,
pointerWithin,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragOverEvent,
DragStartEvent,
DragOverlay,
useDroppable,
useDndContext,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
interface DeckItem {
id: string;
name: string;
description: string | null;
groupId: string | null;
sortOrder: number;
_count: { cards: number };
progress: Array<{
mode: string;
@ -18,12 +43,126 @@ interface DeckItem {
}>;
}
const cache: Record<string, DeckItem[]> = {};
interface MaterialGroup {
id: string;
name: string;
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>)
: {};
const correctCount = Object.values(results).filter((r) => r === "correct").length;
const total = order.length;
const current = Math.min(prog.currentIndex + 1, total);
const modeLabel = prog.mode === "SHUFFLED" ? "shuffled" : "sequential";
return `${current}/${total}, ${modeLabel} · ${correctCount}`;
}
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
const progressLabel = getProgressLabel(deck);
return (
<div ref={setNodeRef} style={style} className="group flex flex-col bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300 h-full">
<div className="p-4 flex flex-col flex-1 h-full">
<div className="flex items-start gap-2">
<div {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-text-muted hover:text-text-heading">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-heading mb-1">{deck.name}</h3>
{deck.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{deck.description}</p>}
</div>
</div>
<div className="flex items-center gap-3 mt-auto mb-4 pl-7">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium">
{deck._count.cards} {deck._count.cards === 1 ? "card" : "cards"}
</span>
{progressLabel && (
<span className="text-xs text-text-muted">{progressLabel}</span>
)}
</div>
<div className="flex items-center gap-2 pt-4 border-t border-border-light pl-7">
{deck.progress?.length ? (
<>
<Link href={`/${classSlug}/flashcards/${deck.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
Continue
</Link>
<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(() => {})
]);
window.location.href = `/${classSlug}/flashcards/${deck.id}`;
}}
className="p-2 rounded-lg text-text-muted border border-border-light hover:bg-bg-surface-alt hover:text-text-heading transition-all duration-200 cursor-pointer"
title="Start New"
>
<svg className="w-5 h-5 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</>
) : (
<Link href={`/${classSlug}/flashcards/${deck.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
Study
</Link>
)}
<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" />
</svg>
</button>
<button onClick={() => onDelete(deck)} className="p-2 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer" title="Delete">
<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>
</div>
</div>
);
}
function DroppableContainer({ id, children }: { id: string; children: React.ReactNode }) {
const { setNodeRef } = useDroppable({ id });
const { over } = useDndContext();
const isOverContainer = over?.id === id;
const isOverChild = over?.data?.current?.sortable?.containerId === id;
const isOver = isOverContainer || isOverChild;
return (
<div ref={setNodeRef} className={`grid grid-cols-1 md:grid-cols-2 gap-4 min-h-[60px] p-2 -mx-2 rounded-lg border-2 transition-colors duration-200 ${isOver ? "border-primary/40 bg-primary/5 shadow-inner" : "border-transparent"}`}>
{children}
</div>
);
}
export default function FlashcardsPage() {
const params = useParams();
const classSlug = params.classSlug as string;
const [decks, setDecks] = useState<DeckItem[]>(cache[classSlug] || []);
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 [showImport, setShowImport] = useState(false);
@ -31,22 +170,29 @@ export default function FlashcardsPage() {
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [isCreatingGroup, setIsCreatingGroup] = useState(false);
const [newGroupName, setNewGroupName] = useState("");
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editGroupName, setEditGroupName] = useState("");
const fetchDecks = useCallback(async (cId: string) => {
const fetchAll = useCallback(async (cId: string) => {
try {
const res = await fetch(`/api/decks/list?classId=${cId}`);
if (res.ok) {
const data = await res.json();
const arr = Array.isArray(data) ? data : [];
setDecks(arr);
cache[classSlug] = arr;
}
const [deckRes, groupRes] = await Promise.all([
fetch(`/api/decks/list?classId=${cId}`),
fetch(`/api/material-groups?classId=${cId}&type=DECK`),
]);
const ds = deckRes.ok ? await deckRes.json() : [];
const gs = groupRes.ok ? await groupRes.json() : [];
setDecks(ds);
setGroups(gs);
cache[classSlug] = { decks: ds, groups: gs };
} catch {
setDecks([]);
setGroups([]);
} finally {
setLoading(false);
}
}, []);
}, [classSlug]);
useEffect(() => {
async function init() {
@ -59,21 +205,56 @@ export default function FlashcardsPage() {
return;
}
setClassId(cls.id);
fetchDecks(cls.id);
fetchAll(cls.id);
} catch {
setLoading(false);
}
}
init();
}, [classSlug, fetchDecks]);
}, [classSlug, fetchAll]);
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete "${name}" and all its cards?`)) return;
await fetch(`/api/decks/${id}`, { method: "DELETE" });
setDecks((prev) => prev.filter((d) => d.id !== id));
// Group Management
async function handleCreateGroup() {
if (!newGroupName.trim()) return;
const res = await fetch("/api/material-groups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ classId, name: newGroupName.trim(), type: "DECK" }),
});
if (res.ok) {
const g = await res.json();
setGroups((prev) => [...prev, g]);
setIsCreatingGroup(false);
setNewGroupName("");
}
}
async function handleRename(id: string) {
async function handleRenameGroup(id: string) {
if (!editGroupName.trim()) return;
await fetch(`/api/material-groups/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editGroupName.trim() }),
});
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
setEditingGroupId(null);
}
async function handleDeleteGroup(id: string, name: string) {
if (!confirm(`Delete group "${name}"? Decks inside will be moved to Uncategorized.`)) return;
await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
setGroups((prev) => prev.filter((g) => g.id !== id));
setDecks((prev) => prev.map((d) => (d.groupId === id ? { ...d, groupId: null } : d)));
}
// Deck Management
async function handleDeleteDeck(deck: DeckItem) {
if (!confirm(`Delete "${deck.name}" and all its cards?`)) return;
await fetch(`/api/decks/${deck.id}`, { method: "DELETE" });
setDecks((prev) => prev.filter((d) => d.id !== deck.id));
}
async function handleRenameDeck(id: string) {
if (!editName.trim()) return;
await fetch(`/api/decks/${id}`, {
method: "PATCH",
@ -86,201 +267,230 @@ export default function FlashcardsPage() {
setEditingId(null);
}
function getProgressLabel(deck: DeckItem) {
if (!deck.progress?.length) return null;
const prog = deck.progress[0];
const order = JSON.parse(prog.orderJson) as string[];
const results = prog.cardResultsJson
? (JSON.parse(prog.cardResultsJson) as Record<string, string>)
: {};
const correctCount = Object.values(results).filter((r) => r === "correct").length;
const total = order.length;
const current = Math.min(prog.currentIndex + 1, total);
const modeLabel = prog.mode === "SHUFFLED" ? "shuffled" : "sequential";
return `${current}/${total}, ${modeLabel} · ${correctCount}`;
// Drag and drop setup
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const [activeId, setActiveId] = useState<string | null>(null);
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string);
}
function handleDragOver(event: DragOverEvent) {
// optional layout updates here
}
async function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
const activeDeck = decks.find((d) => d.id === activeId);
if (!activeDeck) return;
let targetGroupId: string | null = null;
let targetIndex = 0;
const overContainerId = over.data.current?.sortable?.containerId;
if (overContainerId) {
targetGroupId = overContainerId === "uncategorized" ? null : overContainerId;
} else if (groups.find(g => g.id === overId) || overId === "uncategorized") {
targetGroupId = overId === "uncategorized" ? null : overId;
} else {
const overDeck = decks.find((d) => d.id === overId);
if (overDeck) {
targetGroupId = overDeck.groupId;
}
}
if (targetGroupId !== undefined) {
setDecks((items) => {
const oldIndex = items.findIndex((d) => d.id === activeId);
const overIndex = items.findIndex((d) => d.id === overId);
let newItems = [...items];
newItems[oldIndex].groupId = targetGroupId;
if (overIndex >= 0 && overIndex !== oldIndex) {
newItems = arrayMove(newItems, oldIndex, overIndex);
} else {
const oldItem = newItems.splice(oldIndex, 1)[0];
newItems.push(oldItem);
}
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
const updates: any[] = [];
affectedGroups.forEach(gId => {
const gItems = newItems.filter(d => d.groupId === gId);
gItems.forEach((item, index) => {
item.sortOrder = index;
updates.push({ id: item.id, sortOrder: index, groupId: item.groupId });
});
});
fetch("/api/decks/reorder", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: updates }),
});
return newItems;
});
}
}
const groupedDecks = groups.map(g => ({
...g,
decks: decks.filter(d => d.groupId === g.id).sort((a,b) => a.sortOrder - b.sortOrder)
}));
const uncategorizedDecks = decks.filter(d => d.groupId === null).sort((a,b) => a.sortOrder - b.sortOrder);
const activeDeck = decks.find(d => d.id === activeId);
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-text-heading">Flashcard Decks</h2>
<button
onClick={() => setShowImport(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Import Deck
</button>
<div className="flex items-center gap-3">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-bg-surface-alt text-text-heading font-medium border border-border-light hover:bg-border transition-all duration-200 shadow-sm cursor-pointer">
Add Group
</button>
<button onClick={() => setShowImport(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Import Deck
</button>
</div>
</div>
{/* Empty state */}
{!loading && decks.length === 0 && (
<div className={`text-center py-16 ${animate ? "animate-slide-up" : ""}`}>
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-lg font-semibold text-text-heading mb-1">No flashcard decks yet</h3>
<p className="text-text-secondary mb-4">Import your first deck to start studying</p>
<button
onClick={() => setShowImport(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
>
Import Deck
{isCreatingGroup && (
<div className="mb-6 p-4 bg-bg-surface rounded-xl border border-primary/20 shadow-sm flex items-center gap-3">
<input
autoFocus
type="text"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder="e.g. Exam 1 Flashcards"
className="flex-1 px-3 py-2 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<button onClick={handleCreateGroup} className="px-4 py-2 bg-primary text-white font-medium rounded-md hover:bg-primary-hover cursor-pointer">
Save
</button>
<button onClick={() => { setIsCreatingGroup(false); setNewGroupName(""); }} className="px-4 py-2 bg-bg-surface-alt text-text-heading font-medium rounded-md hover:bg-border cursor-pointer">
Cancel
</button>
</div>
)}
{/* Deck grid */}
{!loading && decks.length > 0 && (
<div className={`grid grid-cols-1 md:grid-cols-2 gap-4 ${animate ? "animate-slide-up" : ""}`}>
{decks.map((deck) => {
const progressLabel = getProgressLabel(deck);
return (
<div
key={deck.id}
className="group flex flex-col bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300 h-full"
>
<div className="p-6 flex flex-col flex-1 h-full">
{/* Name and Description (editable) */}
{editingId === deck.id ? (
<div className="flex flex-col gap-3 mb-3">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Deck Name"
autoFocus
className="w-full px-3 py-1.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Description (optional)"
rows={2}
className="w-full px-3 py-1.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
<div className="flex gap-2 mt-1">
<button
onClick={() => handleRename(deck.id)}
className="px-3 py-1.5 bg-primary text-white text-xs font-medium rounded-md hover:bg-primary-hover cursor-pointer"
>
Save Changes
</button>
<button
onClick={() => setEditingId(null)}
className="px-3 py-1.5 bg-bg-surface-alt text-text-heading text-xs font-medium rounded-md hover:bg-border cursor-pointer transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<>
<h3 className="text-lg font-semibold text-text-heading mb-1">
{deck.name}
</h3>
{deck.description && (
<p className="text-sm text-text-secondary mb-3 line-clamp-2">{deck.description}</p>
)}
</>
)}
{/* Stats */}
<div className="flex items-center gap-3 mt-auto mb-4">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium">
{deck._count.cards} {deck._count.cards === 1 ? "card" : "cards"}
</span>
{progressLabel && (
<span className="text-xs text-text-muted">{progressLabel}</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-4 border-t border-border-light">
{deck.progress?.length ? (
<>
<Link
href={`/${classSlug}/flashcards/${deck.id}`}
className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200"
>
Continue
</Link>
<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(() => {})
]);
window.location.href = `/${classSlug}/flashcards/${deck.id}`;
}}
className="p-2 rounded-lg text-text-muted border border-border-light hover:bg-bg-surface-alt hover:text-text-heading transition-all duration-200 cursor-pointer"
title="Start New"
>
<svg className="w-5 h-5 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</>
) : (
<Link
href={`/${classSlug}/flashcards/${deck.id}`}
className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200"
>
Study
</Link>
)}
<button
onClick={() => {
setEditingId(deck.id);
setEditName(deck.name);
setEditDescription(deck.description || "");
}}
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" />
</svg>
</button>
<button
onClick={() => handleDelete(deck.id, deck.name)}
className="p-2 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer"
title="Delete"
>
<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>
</div>
</div>
);
})}
{editingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-bg-surface rounded-xl p-6 w-full max-w-md shadow-lg border border-border">
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Deck</h3>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Deck Name"
autoFocus
className="w-full px-3 py-2 mb-3 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Description (optional)"
rows={3}
className="w-full px-3 py-2 mb-4 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
<div className="flex gap-2 justify-end">
<button onClick={() => setEditingId(null)} className="px-4 py-2 bg-bg-surface-alt text-text-heading font-medium rounded-md hover:bg-border cursor-pointer">Cancel</button>
<button onClick={() => handleRenameDeck(editingId)} className="px-4 py-2 bg-primary text-white font-medium rounded-md hover:bg-primary-hover cursor-pointer">Save Changes</button>
</div>
</div>
</div>
)}
{/* Import Modal */}
{!loading && 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}>
<div className="space-y-8">
{groupedDecks.map(group => (
<div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div className="flex items-center justify-between mb-4">
{editingGroupId === group.id ? (
<div className="flex items-center gap-2">
<input autoFocus value={editGroupName} onChange={e => setEditGroupName(e.target.value)} className="px-2 py-1 rounded border border-border bg-bg-surface text-text-heading focus:ring-1 focus:ring-primary" />
<button onClick={() => handleRenameGroup(group.id)} className="text-xs px-2 py-1 bg-primary text-white rounded">Save</button>
<button onClick={() => setEditingGroupId(null)} className="text-xs px-2 py-1 bg-bg-surface text-text-heading rounded border border-border-light">Cancel</button>
</div>
) : (
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3>
)}
<div className="flex items-center gap-2">
<ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} />
<button onClick={() => { setEditingGroupId(group.id); setEditGroupName(group.name); }} className="text-text-muted hover:text-text-heading"><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="text-text-muted hover:text-error"><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>
</div>
<SortableContext id={group.id} items={group.decks.map(d => d.id)} strategy={verticalListSortingStrategy}>
<DroppableContainer id={group.id}>
{group.decks.length === 0 ? (
<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} />
))
)}
</DroppableContainer>
</SortableContext>
</div>
))}
{/* Uncategorized */}
<div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<h3 className="text-lg font-semibold text-text-heading mb-4">Uncategorized</h3>
<SortableContext id="uncategorized" items={uncategorizedDecks.map(d => d.id)} strategy={verticalListSortingStrategy}>
<DroppableContainer id="uncategorized">
{uncategorizedDecks.length === 0 ? (
<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} />
))
)}
</DroppableContainer>
</SortableContext>
</div>
</div>
<DragOverlay>
{activeDeck ? (
<div className="opacity-80 scale-105 shadow-xl rotate-2">
<SortableDeckCard deck={activeDeck} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} />
</div>
) : null}
</DragOverlay>
</DndContext>
)}
{showImport && (
<ImportModal
classId={classId}
importType="flashcards"
onClose={() => setShowImport(false)}
onImported={() => {
setShowImport(false);
setLoading(true);
// Re-fetch
window.location.reload();
}}
/>
<ImportModal classId={classId} importType="flashcards" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); setLoading(true); window.location.reload(); }} />
)}
</div>
);

View file

@ -4,20 +4,121 @@ import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ImportModal } from "@/components/import/ImportModal";
import { ShareMenu } from "@/components/ui/ShareMenu";
import {
DndContext,
pointerWithin,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragOverEvent,
DragStartEvent,
DragOverlay,
useDroppable,
useDndContext,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
interface QuizItem {
id: string;
name: string;
description: string | null;
groupId: string | null;
sortOrder: number;
_count: { questions: number; attempts: number };
}
const cache: Record<string, QuizItem[]> = {};
interface MaterialGroup {
id: string;
name: string;
sortOrder: number;
}
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<div ref={setNodeRef} style={style} className="group bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300">
<div className="p-4 flex flex-col flex-1 h-full">
<div className="flex items-start gap-2">
<div {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-text-muted hover:text-text-heading">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-heading mb-1">{quiz.name}</h3>
{quiz.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{quiz.description}</p>}
</div>
</div>
<div className="flex items-center gap-3 mt-auto pl-7">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium">
{quiz._count.questions} {quiz._count.questions === 1 ? "question" : "questions"}
</span>
{quiz._count.attempts > 0 && (
<span className="text-xs text-text-muted">
{quiz._count.attempts} {quiz._count.attempts === 1 ? "attempt" : "attempts"}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light pl-7">
<Link href={`/${classSlug}/quizzes/${quiz.id}`} className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200">
Start Quiz
</Link>
<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" />
</svg>
</button>
<button onClick={() => onDelete(quiz)} className="p-2 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer" title="Delete">
<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>
</div>
</div>
);
}
function DroppableContainer({ id, children }: { id: string; children: React.ReactNode }) {
const { setNodeRef } = useDroppable({ id });
const { over } = useDndContext();
const isOverContainer = over?.id === id;
const isOverChild = over?.data?.current?.sortable?.containerId === id;
const isOver = isOverContainer || isOverChild;
return (
<div ref={setNodeRef} className={`grid grid-cols-1 md:grid-cols-2 gap-4 min-h-[60px] p-2 -mx-2 rounded-lg border-2 transition-colors duration-200 ${isOver ? "border-primary/40 bg-primary/5 shadow-inner" : "border-transparent"}`}>
{children}
</div>
);
}
export default function QuizzesPage() {
const params = useParams();
const classSlug = params.classSlug as string;
const [quizzes, setQuizzes] = useState<QuizItem[]>(cache[classSlug] || []);
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 [showImport, setShowImport] = useState(false);
@ -25,22 +126,29 @@ export default function QuizzesPage() {
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [isCreatingGroup, setIsCreatingGroup] = useState(false);
const [newGroupName, setNewGroupName] = useState("");
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editGroupName, setEditGroupName] = useState("");
const fetchQuizzes = useCallback(async (cId: string) => {
const fetchAll = useCallback(async (cId: string) => {
try {
const res = await fetch(`/api/quizzes/list?classId=${cId}`);
if (res.ok) {
const data = await res.json();
const arr = Array.isArray(data) ? data : [];
setQuizzes(arr);
cache[classSlug] = arr;
}
const [quizRes, groupRes] = await Promise.all([
fetch(`/api/quizzes/list?classId=${cId}`),
fetch(`/api/material-groups?classId=${cId}&type=QUIZ`),
]);
const qs = quizRes.ok ? await quizRes.json() : [];
const gs = groupRes.ok ? await groupRes.json() : [];
setQuizzes(qs);
setGroups(gs);
cache[classSlug] = { quizzes: qs, groups: gs };
} catch {
setQuizzes([]);
setGroups([]);
} finally {
setLoading(false);
}
}, []);
}, [classSlug]);
useEffect(() => {
async function init() {
@ -53,21 +161,56 @@ export default function QuizzesPage() {
return;
}
setClassId(cls.id);
fetchQuizzes(cls.id);
fetchAll(cls.id);
} catch {
setLoading(false);
}
}
init();
}, [classSlug, fetchQuizzes]);
}, [classSlug, fetchAll]);
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete "${name}" and all its questions and attempts?`)) return;
await fetch(`/api/quizzes/${id}`, { method: "DELETE" });
setQuizzes((prev) => prev.filter((q) => q.id !== id));
// Group Management
async function handleCreateGroup() {
if (!newGroupName.trim()) return;
const res = await fetch("/api/material-groups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ classId, name: newGroupName.trim(), type: "QUIZ" }),
});
if (res.ok) {
const g = await res.json();
setGroups((prev) => [...prev, g]);
setIsCreatingGroup(false);
setNewGroupName("");
}
}
async function handleRename(id: string) {
async function handleRenameGroup(id: string) {
if (!editGroupName.trim()) return;
await fetch(`/api/material-groups/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editGroupName.trim() }),
});
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
setEditingGroupId(null);
}
async function handleDeleteGroup(id: string, name: string) {
if (!confirm(`Delete group "${name}"? 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)));
}
// 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" });
setQuizzes((prev) => prev.filter((q) => q.id !== quiz.id));
}
async function handleRenameQuiz(id: string) {
if (!editName.trim()) return;
await fetch(`/api/quizzes/${id}`, {
method: "PATCH",
@ -80,155 +223,247 @@ export default function QuizzesPage() {
setEditingId(null);
}
// Drag and drop setup
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const [activeId, setActiveId] = useState<string | null>(null);
function handleDragStart(event: DragStartEvent) {
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;
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
const activeQuiz = quizzes.find((q) => q.id === activeId);
if (!activeQuiz) return;
let targetGroupId: string | null = null;
let targetIndex = 0;
// Check if over a group container directly
const overContainerId = over.data.current?.sortable?.containerId;
if (overContainerId) {
targetGroupId = overContainerId === "uncategorized" ? null : overContainerId;
} else if (groups.find(g => g.id === overId) || overId === "uncategorized") {
targetGroupId = overId === "uncategorized" ? null : overId;
} else {
const overQuiz = quizzes.find((q) => q.id === overId);
if (overQuiz) {
targetGroupId = overQuiz.groupId;
}
}
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;
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);
}
// Re-calculate sortOrder for the affected groups to persist
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
const updates: any[] = [];
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;
});
}
}
// Derive grouped quizzes
const groupedQuizzes = groups.map(g => ({
...g,
quizzes: quizzes.filter(q => q.groupId === g.id).sort((a,b) => a.sortOrder - b.sortOrder)
}));
const uncategorizedQuizzes = quizzes.filter(q => q.groupId === null).sort((a,b) => a.sortOrder - b.sortOrder);
const activeQuiz = quizzes.find(q => q.id === activeId);
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-text-heading">Quizzes</h2>
<button
onClick={() => setShowImport(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Import Quiz
</button>
<div className="flex items-center gap-3">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-bg-surface-alt text-text-heading font-medium border border-border-light hover:bg-border transition-all duration-200 shadow-sm cursor-pointer">
Add Group
</button>
<button onClick={() => setShowImport(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Import Quiz
</button>
</div>
</div>
{/* Empty state */}
{!loading && quizzes.length === 0 && (
<div className={`text-center py-16 ${animate ? "animate-slide-up" : ""}`}>
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<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 to start practicing</p>
<button
onClick={() => setShowImport(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
>
Import Quiz
{isCreatingGroup && (
<div className="mb-6 p-4 bg-bg-surface rounded-xl border border-primary/20 shadow-sm flex items-center gap-3">
<input
autoFocus
type="text"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder="e.g. Exam 1 Quizzes"
className="flex-1 px-3 py-2 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<button onClick={handleCreateGroup} className="px-4 py-2 bg-primary text-white font-medium rounded-md hover:bg-primary-hover cursor-pointer">
Save
</button>
<button onClick={() => { setIsCreatingGroup(false); setNewGroupName(""); }} className="px-4 py-2 bg-bg-surface-alt text-text-heading font-medium rounded-md hover:bg-border cursor-pointer">
Cancel
</button>
</div>
)}
{/* Quiz grid */}
{!loading && quizzes.length > 0 && (
<div className={`grid grid-cols-1 md:grid-cols-2 gap-4 ${animate ? "animate-slide-up" : ""}`}>
{quizzes.map((quiz) => (
<div
key={quiz.id}
className="group bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
>
<div className="p-6 flex flex-col flex-1 h-full">
{/* Name and Description (editable) */}
{editingId === quiz.id ? (
<div className="flex flex-col gap-3 mb-3">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Quiz Name"
autoFocus
className="w-full px-3 py-1.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Description (optional)"
rows={2}
className="w-full px-3 py-1.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
<div className="flex gap-2 mt-1">
<button
onClick={() => handleRename(quiz.id)}
className="px-3 py-1.5 bg-primary text-white text-xs font-medium rounded-md hover:bg-primary-hover cursor-pointer"
>
Save Changes
</button>
<button
onClick={() => setEditingId(null)}
className="px-3 py-1.5 bg-bg-surface-alt text-text-heading text-xs font-medium rounded-md hover:bg-border cursor-pointer transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<>
<h3 className="text-lg font-semibold text-text-heading mb-1">
{quiz.name}
</h3>
{quiz.description && (
<p className="text-sm text-text-secondary mb-3 line-clamp-2">
{quiz.description}
</p>
)}
</>
)}
<div className="flex items-center gap-3 mt-auto">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium">
{quiz._count.questions} {quiz._count.questions === 1 ? "question" : "questions"}
</span>
{quiz._count.attempts > 0 && (
<span className="text-xs text-text-muted">
{quiz._count.attempts} {quiz._count.attempts === 1 ? "attempt" : "attempts"}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light">
<Link
href={`/${classSlug}/quizzes/${quiz.id}`}
className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200"
>
Start Quiz
</Link>
<button
onClick={() => {
setEditingId(quiz.id);
setEditName(quiz.name);
setEditDescription(quiz.description || "");
}}
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" />
</svg>
</button>
<button
onClick={() => handleDelete(quiz.id, quiz.name)}
className="p-2 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer"
title="Delete"
>
<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>
</div>
{/* Editing quiz modal/inline - simplified as a modal-like overlay for simplicity when editing */}
{editingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-bg-surface rounded-xl p-6 w-full max-w-md shadow-lg border border-border">
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Quiz</h3>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Quiz Name"
autoFocus
className="w-full px-3 py-2 mb-3 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Description (optional)"
rows={3}
className="w-full px-3 py-2 mb-4 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
<div className="flex gap-2 justify-end">
<button onClick={() => setEditingId(null)} className="px-4 py-2 bg-bg-surface-alt text-text-heading font-medium rounded-md hover:bg-border cursor-pointer">Cancel</button>
<button onClick={() => handleRenameQuiz(editingId)} className="px-4 py-2 bg-primary text-white font-medium rounded-md hover:bg-primary-hover cursor-pointer">Save Changes</button>
</div>
))}
</div>
</div>
)}
{/* Import Modal */}
{!loading && 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}>
<div className="space-y-8">
{groupedQuizzes.map(group => (
<div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div className="flex items-center justify-between mb-4">
{editingGroupId === group.id ? (
<div className="flex items-center gap-2">
<input autoFocus value={editGroupName} onChange={e => setEditGroupName(e.target.value)} className="px-2 py-1 rounded border border-border bg-bg-surface text-text-heading focus:ring-1 focus:ring-primary" />
<button onClick={() => handleRenameGroup(group.id)} className="text-xs px-2 py-1 bg-primary text-white rounded">Save</button>
<button onClick={() => setEditingGroupId(null)} className="text-xs px-2 py-1 bg-bg-surface text-text-heading rounded border border-border-light">Cancel</button>
</div>
) : (
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3>
)}
<div className="flex items-center gap-2">
<ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} />
<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>
</div>
<SortableContext id={group.id} items={group.quizzes.map(q => q.id)} strategy={verticalListSortingStrategy}>
<DroppableContainer id={group.id}>
{group.quizzes.length === 0 ? (
<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} />
))
)}
</DroppableContainer>
</SortableContext>
</div>
))}
{/* Uncategorized */}
<div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<h3 className="text-lg font-semibold text-text-heading mb-4">Uncategorized</h3>
<SortableContext id="uncategorized" items={uncategorizedQuizzes.map(q => q.id)} strategy={verticalListSortingStrategy}>
<DroppableContainer id="uncategorized">
{uncategorizedQuizzes.length === 0 ? (
<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} />
))
)}
</DroppableContainer>
</SortableContext>
</div>
</div>
<DragOverlay>
{activeQuiz ? (
<div className="opacity-80 scale-105 shadow-xl rotate-2">
<SortableQuizCard quiz={activeQuiz} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} />
</div>
) : null}
</DragOverlay>
</DndContext>
)}
{showImport && (
<ImportModal
classId={classId}
importType="quizzes"
onClose={() => setShowImport(false)}
onImported={() => {
setShowImport(false);
window.location.reload();
}}
/>
<ImportModal classId={classId} importType="quizzes" onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); window.location.reload(); }} />
)}
</div>
);

View file

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const { items } = body;
if (!Array.isArray(items)) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
// items should be an array of { id, sortOrder, groupId }
// Using a transaction to perform all updates
await prisma.$transaction(
items.map((item: any) =>
prisma.deck.update({
where: { id: item.id },
data: {
sortOrder: item.sortOrder,
groupId: item.groupId,
},
})
)
);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: "Failed to reorder decks" }, { status: 500 });
}
}

View file

@ -9,7 +9,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { classId, data, name } = body;
const { classId, data, name, groupId } = body;
if (!classId || typeof classId !== "string") {
return NextResponse.json({ error: "classId is required" }, { status: 400 });
@ -27,7 +27,8 @@ export async function POST(request: NextRequest) {
const deck = await deckService.createDeckFromImport(
classId,
parsed.data,
name?.trim() || undefined
name?.trim() || undefined,
groupId || null
);
return NextResponse.json(deck, { status: 201 });

View file

@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const { name, sortOrder } = body;
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
const group = await prisma.materialGroup.update({
where: { id },
data: updateData,
});
return NextResponse.json(group);
} catch (error) {
return NextResponse.json({ error: "Failed to update material group" }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await prisma.materialGroup.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: "Failed to delete material group" }, { status: 500 });
}
}

View file

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const classId = searchParams.get("classId");
const type = searchParams.get("type");
if (!classId) {
return NextResponse.json({ error: "classId is required" }, { status: 400 });
}
if (type && type !== "DECK" && type !== "QUIZ") {
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
}
const whereClause: any = { classId };
if (type) {
whereClause.type = type;
}
try {
const groups = await prisma.materialGroup.findMany({
where: whereClause,
orderBy: { sortOrder: "asc" },
});
return NextResponse.json(groups);
} catch (error) {
return NextResponse.json({ error: "Failed to fetch material groups" }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { classId, name, type } = body;
if (!classId || !name || (type !== "DECK" && type !== "QUIZ")) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const maxOrder = await prisma.materialGroup.aggregate({
where: { classId, type },
_max: { sortOrder: true },
});
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
const group = await prisma.materialGroup.create({
data: { classId, name, type, sortOrder },
});
return NextResponse.json(group, { status: 201 });
} catch (error) {
return NextResponse.json({ error: "Failed to create material group" }, { status: 500 });
}
}

View file

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const { items } = body;
if (!Array.isArray(items)) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
// items should be an array of { id, sortOrder, groupId }
// Using a transaction to perform all updates
await prisma.$transaction(
items.map((item: any) =>
prisma.quizSet.update({
where: { id: item.id },
data: {
sortOrder: item.sortOrder,
groupId: item.groupId,
},
})
)
);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: "Failed to reorder quizzes" }, { status: 500 });
}
}

View file

@ -9,7 +9,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { classId, data, name } = body;
const { classId, data, name, groupId } = body;
if (!classId || typeof classId !== "string") {
return NextResponse.json({ error: "classId is required" }, { status: 400 });
@ -27,7 +27,8 @@ export async function POST(request: NextRequest) {
const quizSet = await quizService.createQuizSetFromImport(
classId,
parsed.data,
name?.trim() || undefined
name?.trim() || undefined,
groupId || null
);
return NextResponse.json(quizSet, { status: 201 });

View file

@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { toggleShareLink, getShareLinkForContent } from "@/services/shareService";
import { toggleShareLink, getShareLinkForContent, isContentSharedViaGroup } from "@/services/shareService";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const targetType = searchParams.get("targetType") as "DECK" | "QUIZ";
const targetType = searchParams.get("targetType") as "DECK" | "QUIZ" | "GROUP";
const contentId = searchParams.get("contentId");
if (!targetType || !contentId) {
@ -14,7 +14,19 @@ export async function GET(request: NextRequest) {
}
const link = await getShareLinkForContent(targetType, contentId);
return NextResponse.json({ token: link?.id || null });
if (targetType === "DECK" || targetType === "QUIZ") {
const groupShare = await isContentSharedViaGroup(targetType, contentId);
if (groupShare) {
return NextResponse.json({
token: groupShare.token,
isGroupShared: true,
groupName: groupShare.groupName,
});
}
}
return NextResponse.json({ token: link?.id || null, isGroupShared: false });
}
export async function POST(request: NextRequest) {
@ -27,6 +39,6 @@ export async function POST(request: NextRequest) {
);
}
const link = await toggleShareLink(body.targetType, body.contentId);
const link = await toggleShareLink(body.targetType as "DECK" | "QUIZ" | "GROUP", body.contentId);
return NextResponse.json({ token: link?.id || null });
}

View file

@ -0,0 +1,133 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
export function SharedGroupViewer({ data }: { data: any }) {
const pathname = usePathname();
const router = useRouter();
// Combine decks and quizSets if needed, but a group is either DECK or QUIZ type.
const items = data.type === "DECK" ? data.decks : data.quizSets;
const [progressMap, setProgressMap] = useState<Record<string, { currentIndex: number, total: number }>>({});
useEffect(() => {
if (typeof window === 'undefined') return;
const newProgress: Record<string, { currentIndex: number, total: number }> = {};
items.forEach((item: any) => {
const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`;
const saved = localStorage.getItem(key);
if (saved) {
try {
const parsed = JSON.parse(saved);
const total = data.type === "DECK" ? (parsed.order?.length || item.cards?.length || 0) : (parsed.order?.length || item.questions?.length || 0);
newProgress[item.id] = {
currentIndex: parsed.currentIndex || 0,
total,
};
} catch(e) {}
}
});
setProgressMap(newProgress);
}, [items, data.type]);
const handleRestart = (e: React.MouseEvent, itemId: string) => {
e.preventDefault();
const key = data.type === "DECK" ? `flashcard_progress_${itemId}` : `quiz_progress_${itemId}`;
localStorage.removeItem(key);
setProgressMap(prev => {
const next = { ...prev };
delete next[itemId];
return next;
});
router.push(`${pathname}?itemId=${itemId}`);
};
if (!items || items.length === 0) {
return (
<div className="text-center py-12">
<h2 className="text-xl font-medium text-text-muted mb-2">This group is empty</h2>
<p className="text-text-secondary">There are no study materials in this group yet.</p>
</div>
);
}
return (
<div>
<div className="mb-8 text-center md:text-left">
<h1 className="text-3xl font-bold font-serif text-text-heading mb-2">
{data.name}
</h1>
<p className="text-text-secondary">
From class: <span className="font-medium text-text-heading">{data.class.name}</span>
</p>
<div className="mt-4 inline-block px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium">
{data.type === "DECK" ? "Flashcard Group" : "Quiz Group"}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{items.map((item: any) => (
<div
key={item.id}
className="group flex flex-col bg-bg-surface border border-border-light rounded-xl shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
>
<div className="p-4 flex flex-col flex-1 h-full">
<div className="flex items-start gap-2">
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-heading mb-1">{item.name}</h3>
{item.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{item.description}</p>}
</div>
</div>
<div className="flex items-center gap-3 mt-auto pl-1">
{progressMap[item.id] ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium font-bold">
{progressMap[item.id].currentIndex} / {progressMap[item.id].total} {data.type === "DECK" ? "cards" : "questions"}
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium">
{data.type === "DECK" ? `${item.cards?.length || 0} cards` : `${item.questions?.length || 0} questions`}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light pl-1">
{progressMap[item.id] ? (
<>
<Link
href={`${pathname}?itemId=${item.id}`}
className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200"
>
Resume
</Link>
<button
onClick={(e) => handleRestart(e, item.id)}
className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer border border-border-light"
title="Restart"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</>
) : (
<Link
href={`${pathname}?itemId=${item.id}`}
className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200"
>
Study
</Link>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
import { QuizViewer } from "@/components/quizzes/QuizViewer";
import { CardList } from "@/components/flashcards/CardList";
@ -8,11 +10,29 @@ import { CardList } from "@/components/flashcards/CardList";
interface SharedViewerProps {
type: string;
data: any;
groupMode?: boolean;
}
export function SharedViewer({ type, data }: SharedViewerProps) {
export function SharedViewer({ type, data, groupMode = false }: SharedViewerProps) {
const pathname = usePathname();
const [restartKey, setRestartKey] = useState(0);
const [view, setView] = useState<"study" | "list">("study");
const [hasSavedSession, setHasSavedSession] = useState(false);
useEffect(() => {
// Check if there's a saved session for this item
const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`;
if (localStorage.getItem(key)) {
setHasSavedSession(true);
}
}, [type, data.id, restartKey]);
function handleRestart() {
const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`;
localStorage.removeItem(key);
setHasSavedSession(false);
setRestartKey(k => k + 1);
}
const [showTopics, setShowTopics] = useState(false);
const categories = type === "quizzes" && data.questions
@ -21,6 +41,14 @@ export function SharedViewer({ type, data }: SharedViewerProps) {
return (
<div>
{groupMode && (
<div className="mb-6">
<Link href={pathname} className="inline-flex items-center gap-2 text-sm font-medium text-text-muted hover:text-primary transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
Back to Group
</Link>
</div>
)}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-8">
<div className="text-left">
{type === "quizzes" ? (
@ -63,15 +91,16 @@ export function SharedViewer({ type, data }: SharedViewerProps) {
</div>
<div className="flex items-center justify-between w-full md:w-auto gap-2 md:gap-4 self-end md:self-auto">
{view === "study" && (
{view === "study" && hasSavedSession && (
<button
onClick={() => setRestartKey(k => k + 1)}
className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
onClick={handleRestart}
className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer flex items-center gap-2 font-medium text-sm"
title="Restart Session"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span className="hidden md:inline">Restart</span>
</button>
)}

View file

@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import Link from "next/link";
import { SharedViewer } from "./SharedViewer";
import { SharedGroupViewer } from "./SharedGroupViewer";
interface SharedPageProps {
params: Promise<{
@ -12,10 +13,11 @@ interface SharedPageProps {
}>;
}
export default async function SharedPage(props: SharedPageProps) {
export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) {
const { classSlug, type, token } = await props.params;
const { itemId } = await props.searchParams;
if (type !== "flashcards" && type !== "quizzes") {
if (type !== "flashcards" && type !== "quizzes" && type !== "groups") {
notFound();
}
@ -28,12 +30,34 @@ export default async function SharedPage(props: SharedPageProps) {
// Validate that the link matches the URL structure
if (type === "flashcards" && !link.deck) notFound();
if (type === "quizzes" && !link.quizSet) notFound();
if (type === "groups" && !link.group) notFound();
// Validate class slug matches
const targetClassSlug = link.deck ? link.deck.class.slug : link.quizSet?.class.slug;
const targetClassSlug = link.deck ? link.deck.class.slug : (link.quizSet ? link.quizSet.class.slug : link.group?.class.slug);
if (targetClassSlug !== classSlug) {
notFound();
}
let targetData: any = null;
let targetType = type;
if (type === "groups" && link.group) {
if (itemId) {
// Find the specific item inside the group
if (link.group.type === "DECK") {
targetData = link.group.decks.find(d => d.id === itemId);
targetType = "flashcards";
} else {
targetData = link.group.quizSets.find(q => q.id === itemId);
targetType = "quizzes";
}
if (!targetData) notFound();
} else {
targetData = link.group;
}
} else {
targetData = type === "flashcards" ? link.deck : link.quizSet;
}
return (
<div className="min-h-screen bg-bg-base flex flex-col">
@ -45,7 +69,7 @@ export default async function SharedPage(props: SharedPageProps) {
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
Shared {type === "flashcards" ? "Deck" : "Quiz"}
Shared {type === "groups" ? "Group" : (type === "flashcards" ? "Deck" : "Quiz")}
</div>
<div className="flex items-center gap-4">
@ -69,10 +93,15 @@ export default async function SharedPage(props: SharedPageProps) {
{/* Main Content */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<SharedViewer
type={type}
data={type === "flashcards" ? link.deck : link.quizSet}
/>
{type === "groups" && !itemId ? (
<SharedGroupViewer data={targetData} />
) : (
<SharedViewer
type={targetType}
data={targetData}
groupMode={type === "groups"}
/>
)}
</div>
</main>
</div>