"use client"; // Révision de cartes mémoire : file dues + nouvelles, carte retournable (flip 3D), // 4 boutons de qualité SM-2 (raccourcis 1-4), suspension, favori, création manuelle // et génération par IA. import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { CheckCircle2, PauseCircle, Plus, Sparkles, Star } from "lucide-react"; import { Badge, Button, Card, EmptyState, Label, Modal, Skeleton, Spinner, Textarea, cn } from "@/components/ui"; import { Markdown } from "@/components/chat/markdown"; import { ErrorBanner, fetchJson, postJson } from "./shared"; type CardItem = { id: number; type: string; front: string; back: string; concept_id: number | null; concept_name: string | null; favorite?: number; reps?: number; interval_days?: number; }; type Stats = { total: number; learned: number; suspended: number }; type ConceptOpt = { slug: string; name: string }; const TYPE_LABELS: Record = { qa: "Question", definition: "Définition", formula: "Formule", error: "Piège", comparison: "Comparaison", calc: "Calcul", }; const QUALITIES = [ { q: 2 as const, key: "1", label: "Encore", hint: "Réponse oubliée — revue dans 10 min", cls: "bg-red-600 hover:bg-red-700 text-white" }, { q: 3 as const, key: "2", label: "Difficile", hint: "Trouvée avec effort", cls: "bg-amber-500 hover:bg-amber-600 text-white" }, { q: 4 as const, key: "3", label: "Bien", hint: "Trouvée après réflexion", cls: "bg-brand-600 hover:bg-brand-700 text-white" }, { q: 5 as const, key: "4", label: "Facile", hint: "Réponse immédiate", cls: "bg-emerald-600 hover:bg-emerald-700 text-white" }, ]; export function FlashcardsApp({ course }: { course: string }) { const searchParams = useSearchParams(); const conceptFilter = searchParams.get("concept"); const [queue, setQueue] = useState(null); const [initialCount, setInitialCount] = useState(0); const [doneCount, setDoneCount] = useState(0); const [stats, setStats] = useState(null); const [revealed, setRevealed] = useState(false); const [posting, setPosting] = useState(false); const [favorites, setFavorites] = useState>({}); const [error, setError] = useState(null); const [concepts, setConcepts] = useState([]); // Modales const [createOpen, setCreateOpen] = useState(false); const [generateOpen, setGenerateOpen] = useState(false); const [form, setForm] = useState({ conceptSlug: "", type: "qa", front: "", back: "" }); const [genForm, setGenForm] = useState({ conceptSlug: "", count: 5 }); const [modalBusy, setModalBusy] = useState(false); const [modalMsg, setModalMsg] = useState(null); const load = useCallback(async () => { setQueue(null); setError(null); setRevealed(false); setDoneCount(0); try { const url = `/api/learning/${course}/flashcards${conceptFilter ? `?concept=${encodeURIComponent(conceptFilter)}` : ""}`; const d = await fetchJson<{ due: CardItem[]; fresh: CardItem[]; stats: Stats }>(url); const q = [...d.due, ...d.fresh]; setQueue(q); setInitialCount(q.length); setStats(d.stats); const fav: Record = {}; for (const c of d.due) fav[c.id] = !!c.favorite; setFavorites(fav); } catch (e) { setError(e instanceof Error ? e.message : "Erreur de chargement."); setQueue([]); } }, [course, conceptFilter]); useEffect(() => { load(); }, [load]); useEffect(() => { fetchJson<{ nodes: { slug: string; name: string }[] }>(`/api/learning/${course}/concepts`) .then((d) => setConcepts(d.nodes.map((n) => ({ slug: n.slug, name: n.name })))) .catch(() => {}); }, [course]); const current = queue?.[0] ?? null; const rate = useCallback(async (q: 2 | 3 | 4 | 5) => { if (!current || posting) return; setPosting(true); setError(null); try { await postJson("/api/learning/flashcards/review", { cardId: current.id, q, favorite: !!favorites[current.id], }); setQueue((prev) => (prev ? prev.slice(1) : prev)); setDoneCount((n) => n + 1); setRevealed(false); } catch (e) { setError(e instanceof Error ? e.message : "Erreur d'enregistrement."); } finally { setPosting(false); } }, [current, posting, favorites]); async function suspendCurrent() { if (!current || posting) return; setPosting(true); setError(null); try { await postJson("/api/learning/flashcards/review", { cardId: current.id, q: 3, suspend: true, favorite: !!favorites[current.id], }); setQueue((prev) => (prev ? prev.slice(1) : prev)); setDoneCount((n) => n + 1); setStats((s) => (s ? { ...s, suspended: s.suspended + 1 } : s)); setRevealed(false); } catch (e) { setError(e instanceof Error ? e.message : "Erreur d'enregistrement."); } finally { setPosting(false); } } // Raccourcis clavier : espace = retourner, 1-4 = qualité useEffect(() => { const onKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (createOpen || generateOpen || target.tagName === "TEXTAREA" || target.tagName === "INPUT" || target.tagName === "SELECT") return; if (e.key === " " || e.key === "Enter") { if (current) { e.preventDefault(); setRevealed((r) => !r); } } if (revealed && ["1", "2", "3", "4"].includes(e.key)) { e.preventDefault(); const qb = QUALITIES.find((x) => x.key === e.key); if (qb) rate(qb.q); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [current, revealed, rate, createOpen, generateOpen]); async function submitCreate(e: React.FormEvent) { e.preventDefault(); setModalBusy(true); setModalMsg(null); try { await postJson(`/api/learning/${course}/flashcards`, { action: "create", conceptSlug: form.conceptSlug || null, type: form.type, front: form.front, back: form.back, }); setCreateOpen(false); setForm({ conceptSlug: "", type: "qa", front: "", back: "" }); load(); } catch (err) { setModalMsg(err instanceof Error ? err.message : "Erreur de création."); } finally { setModalBusy(false); } } async function submitGenerate(e: React.FormEvent) { e.preventDefault(); if (!genForm.conceptSlug) { setModalMsg("Choisissez un concept."); return; } setModalBusy(true); setModalMsg(null); try { const d = await postJson<{ created: number }>(`/api/learning/${course}/flashcards`, { action: "generate", conceptSlug: genForm.conceptSlug, count: genForm.count, }); setModalMsg(`${d.created} carte${d.created > 1 ? "s" : ""} créée${d.created > 1 ? "s" : ""} à partir du matériel du cours.`); load(); } catch (err) { setModalMsg(err instanceof Error ? err.message : "Erreur de génération."); } finally { setModalBusy(false); } } const conceptName = useMemo( () => concepts.find((c) => c.slug === conceptFilter)?.name ?? conceptFilter, [concepts, conceptFilter] ); const cardRef = useRef(null); return (

Cartes mémoire

{conceptFilter ? <>Ciblage : {conceptName} · : null} Répétition espacée — notez honnêtement, l'algorithme fait le reste.

{/* Stats */} {stats && (
{stats.total} carte{stats.total > 1 ? "s" : ""} au total {stats.learned} apprise{stats.learned > 1 ? "s" : ""} {stats.suspended} suspendue{stats.suspended > 1 ? "s" : ""} {initialCount > 0 && ( Session : {Math.min(doneCount + 1, initialCount)}/{initialCount} )}
)} {error && } {queue === null ? (
) : !current ? ( } title={doneCount > 0 ? "Session terminée — bravo !" : "Tout est à jour !"} description={ doneCount > 0 ? `${doneCount} carte${doneCount > 1 ? "s" : ""} revue${doneCount > 1 ? "s" : ""}. Les prochaines échéances arriveront selon vos notes.` : "Aucune carte due pour l'instant. Revenez demain, ou créez / générez de nouvelles cartes pour apprendre davantage." } action={
} /> ) : (
{/* Métadonnées de la carte */}
{TYPE_LABELS[current.type] ?? current.type} {current.concept_name && {current.concept_name}} {current.reps == null && Nouvelle carte}
{/* Carte retournable */} {/* Boutons de qualité */} {revealed ? (
{QUALITIES.map((b) => ( ))}
) : ( )}
)} {/* Modale : créer une carte */} setCreateOpen(false)} title="Créer une carte">