"use client"; 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"; import type { ReorderItem } from "@/types/study"; interface DeckItem { id: string; name: string; description: string | null; groupId: string | null; sortOrder: number; _count: { cards: number }; progress: Array<{ mode: string; currentIndex: number; orderJson: string; cardResultsJson: string | null; }>; } interface MaterialGroup { id: string; name: string; sortOrder: number; } const cache: Record = {}; function getProgressLabel(deck: DeckItem) { if (!deck.progress?.length) return null; const prog = deck.progress[0]; const order = JSON.parse(prog.orderJson) as string[]; const results = prog.cardResultsJson ? (JSON.parse(prog.cardResultsJson) as Record) : {}; const correctCount = Object.values(results).filter((r) => r === "correct").length; const total = order.length; const current = Math.min(prog.currentIndex + 1, total); const modeLabel = prog.mode === "SHUFFLED" ? "shuffled" : "sequential"; return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`; } interface SortableDeckCardProps { deck: DeckItem; onEdit: (deck: DeckItem) => void; onDelete: (deck: DeckItem) => void; classSlug: string; } function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCardProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1, }; const progressLabel = getProgressLabel(deck); return (

{deck.name}

{deck.description &&

{deck.description}

}
{deck._count.cards} {deck._count.cards === 1 ? "card" : "cards"} {progressLabel && ( {progressLabel} )}
{deck.progress?.length ? ( <> Continue ) : ( Study )}
); } function DroppableContainer({ id, children }: { id: string; children: React.ReactNode }) { const { setNodeRef } = useDroppable({ id }); const { over } = useDndContext(); const isOverContainer = over?.id === id; const isOverChild = over?.data?.current?.sortable?.containerId === id; const isOver = isOverContainer || isOverChild; return (
{children}
); } export default function FlashcardsPage() { const params = useParams(); const classSlug = params.classSlug as string; const [decks, setDecks] = useState(cache[classSlug]?.decks || []); const [groups, setGroups] = useState(cache[classSlug]?.groups || []); const [loading, setLoading] = useState(!cache[classSlug]); const [animate] = useState(!cache[classSlug]); const [showImport, setShowImport] = useState(false); const [classId, setClassId] = useState(""); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); const [editDescription, setEditDescription] = useState(""); const [isCreatingGroup, setIsCreatingGroup] = useState(false); const [newGroupName, setNewGroupName] = useState(""); const [editingGroupId, setEditingGroupId] = useState(null); const [editGroupName, setEditGroupName] = useState(""); const [collapsedGroups, setCollapsedGroups] = useState>({}); useEffect(() => { const saved = localStorage.getItem('flashcards_collapsed_groups'); if (saved) { try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {} } }, []); function toggleGroup(id: string) { setCollapsedGroups(prev => { const next = { ...prev, [id]: !prev[id] }; localStorage.setItem('flashcards_collapsed_groups', JSON.stringify(next)); return next; }); } const fetchAll = useCallback(async (cId: string) => { try { 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() { try { const classRes = await fetch("/api/classes"); const classes = await classRes.json(); const cls = classes.find((c: { slug: string }) => c.slug === classSlug); if (!cls) { setLoading(false); return; } setClassId(cls.id); fetchAll(cls.id); } catch { setLoading(false); } } init(); }, [classSlug, fetchAll]); // 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 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", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }), }); setDecks((prev) => prev.map((d) => (d.id === id ? { ...d, name: editName.trim(), description: editDescription.trim() || null } : d)) ); setEditingId(null); } // Drag and drop setup const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ); const [activeId, setActiveId] = useState(null); function handleDragStart(event: DragStartEvent) { setActiveId(event.active.id as string); } function handleDragOver(event: DragOverEvent) { // optional layout updates here } async function handleDragEnd(event: DragEndEvent) { setActiveId(null); const { active, over } = event; if (!over) return; const activeId = active.id as string; const overId = over.id as string; const activeDeck = decks.find((d) => d.id === activeId); if (!activeDeck) return; let targetGroupId: string | null = null; 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: 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; }); } } const groupedDecks = groups.map(g => ({ ...g, decks: decks.filter(d => d.groupId === g.id).sort((a,b) => a.sortOrder - b.sortOrder) })); const uncategorizedDecks = decks.filter(d => d.groupId === null).sort((a,b) => a.sortOrder - b.sortOrder); const activeDeck = decks.find(d => d.id === activeId); return (
{/* Header */}

Collection

Flashcard decks

{isCreatingGroup && (
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" />
)} {editingId && (

Edit deck

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" />