TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Révision de cartes mémoire : file dues + nouvelles, carte retournable (flip 3D),3// 4 boutons de qualité SM-2 (raccourcis 1-4), suspension, favori, création manuelle4// et génération par IA.5import { useCallback, useEffect, useMemo, useRef, useState } from "react";6import { useSearchParams } from "next/navigation";7import { CheckCircle2, PauseCircle, Plus, Sparkles, Star } from "lucide-react";8import { Badge, Button, Card, EmptyState, Label, Modal, Skeleton, Spinner, Textarea, cn } from "@/components/ui";9import { Markdown } from "@/components/chat/markdown";10import { ErrorBanner, fetchJson, postJson } from "./shared";1112type CardItem = {13 id: number; type: string; front: string; back: string;14 concept_id: number | null; concept_name: string | null;15 favorite?: number; reps?: number; interval_days?: number;16};17type Stats = { total: number; learned: number; suspended: number };18type ConceptOpt = { slug: string; name: string };1920const TYPE_LABELS: Record<string, string> = {21 qa: "Question", definition: "Définition", formula: "Formule",22 error: "Piège", comparison: "Comparaison", calc: "Calcul",23};2425const QUALITIES = [26 { 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" },27 { q: 3 as const, key: "2", label: "Difficile", hint: "Trouvée avec effort", cls: "bg-amber-500 hover:bg-amber-600 text-white" },28 { 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" },29 { q: 5 as const, key: "4", label: "Facile", hint: "Réponse immédiate", cls: "bg-emerald-600 hover:bg-emerald-700 text-white" },30];3132export function FlashcardsApp({ course }: { course: string }) {33 const searchParams = useSearchParams();34 const conceptFilter = searchParams.get("concept");3536 const [queue, setQueue] = useState<CardItem[] | null>(null);37 const [initialCount, setInitialCount] = useState(0);38 const [doneCount, setDoneCount] = useState(0);39 const [stats, setStats] = useState<Stats | null>(null);40 const [revealed, setRevealed] = useState(false);41 const [posting, setPosting] = useState(false);42 const [favorites, setFavorites] = useState<Record<number, boolean>>({});43 const [error, setError] = useState<string | null>(null);44 const [concepts, setConcepts] = useState<ConceptOpt[]>([]);4546 // Modales47 const [createOpen, setCreateOpen] = useState(false);48 const [generateOpen, setGenerateOpen] = useState(false);49 const [form, setForm] = useState({ conceptSlug: "", type: "qa", front: "", back: "" });50 const [genForm, setGenForm] = useState({ conceptSlug: "", count: 5 });51 const [modalBusy, setModalBusy] = useState(false);52 const [modalMsg, setModalMsg] = useState<string | null>(null);5354 const load = useCallback(async () => {55 setQueue(null);56 setError(null);57 setRevealed(false);58 setDoneCount(0);59 try {60 const url = `/api/learning/${course}/flashcards${conceptFilter ? `?concept=${encodeURIComponent(conceptFilter)}` : ""}`;61 const d = await fetchJson<{ due: CardItem[]; fresh: CardItem[]; stats: Stats }>(url);62 const q = [...d.due, ...d.fresh];63 setQueue(q);64 setInitialCount(q.length);65 setStats(d.stats);66 const fav: Record<number, boolean> = {};67 for (const c of d.due) fav[c.id] = !!c.favorite;68 setFavorites(fav);69 } catch (e) {70 setError(e instanceof Error ? e.message : "Erreur de chargement.");71 setQueue([]);72 }73 }, [course, conceptFilter]);7475 useEffect(() => { load(); }, [load]);7677 useEffect(() => {78 fetchJson<{ nodes: { slug: string; name: string }[] }>(`/api/learning/${course}/concepts`)79 .then((d) => setConcepts(d.nodes.map((n) => ({ slug: n.slug, name: n.name }))))80 .catch(() => {});81 }, [course]);8283 const current = queue?.[0] ?? null;8485 const rate = useCallback(async (q: 2 | 3 | 4 | 5) => {86 if (!current || posting) return;87 setPosting(true);88 setError(null);89 try {90 await postJson("/api/learning/flashcards/review", {91 cardId: current.id, q, favorite: !!favorites[current.id],92 });93 setQueue((prev) => (prev ? prev.slice(1) : prev));94 setDoneCount((n) => n + 1);95 setRevealed(false);96 } catch (e) {97 setError(e instanceof Error ? e.message : "Erreur d'enregistrement.");98 } finally {99 setPosting(false);100 }101 }, [current, posting, favorites]);102103 async function suspendCurrent() {104 if (!current || posting) return;105 setPosting(true);106 setError(null);107 try {108 await postJson("/api/learning/flashcards/review", {109 cardId: current.id, q: 3, suspend: true, favorite: !!favorites[current.id],110 });111 setQueue((prev) => (prev ? prev.slice(1) : prev));112 setDoneCount((n) => n + 1);113 setStats((s) => (s ? { ...s, suspended: s.suspended + 1 } : s));114 setRevealed(false);115 } catch (e) {116 setError(e instanceof Error ? e.message : "Erreur d'enregistrement.");117 } finally {118 setPosting(false);119 }120 }121122 // Raccourcis clavier : espace = retourner, 1-4 = qualité123 useEffect(() => {124 const onKey = (e: KeyboardEvent) => {125 const target = e.target as HTMLElement;126 if (createOpen || generateOpen || target.tagName === "TEXTAREA" || target.tagName === "INPUT" || target.tagName === "SELECT") return;127 if (e.key === " " || e.key === "Enter") {128 if (current) { e.preventDefault(); setRevealed((r) => !r); }129 }130 if (revealed && ["1", "2", "3", "4"].includes(e.key)) {131 e.preventDefault();132 const qb = QUALITIES.find((x) => x.key === e.key);133 if (qb) rate(qb.q);134 }135 };136 window.addEventListener("keydown", onKey);137 return () => window.removeEventListener("keydown", onKey);138 }, [current, revealed, rate, createOpen, generateOpen]);139140 async function submitCreate(e: React.FormEvent) {141 e.preventDefault();142 setModalBusy(true);143 setModalMsg(null);144 try {145 await postJson(`/api/learning/${course}/flashcards`, {146 action: "create",147 conceptSlug: form.conceptSlug || null,148 type: form.type,149 front: form.front,150 back: form.back,151 });152 setCreateOpen(false);153 setForm({ conceptSlug: "", type: "qa", front: "", back: "" });154 load();155 } catch (err) {156 setModalMsg(err instanceof Error ? err.message : "Erreur de création.");157 } finally {158 setModalBusy(false);159 }160 }161162 async function submitGenerate(e: React.FormEvent) {163 e.preventDefault();164 if (!genForm.conceptSlug) { setModalMsg("Choisissez un concept."); return; }165 setModalBusy(true);166 setModalMsg(null);167 try {168 const d = await postJson<{ created: number }>(`/api/learning/${course}/flashcards`, {169 action: "generate", conceptSlug: genForm.conceptSlug, count: genForm.count,170 });171 setModalMsg(`${d.created} carte${d.created > 1 ? "s" : ""} créée${d.created > 1 ? "s" : ""} à partir du matériel du cours.`);172 load();173 } catch (err) {174 setModalMsg(err instanceof Error ? err.message : "Erreur de génération.");175 } finally {176 setModalBusy(false);177 }178 }179180 const conceptName = useMemo(181 () => concepts.find((c) => c.slug === conceptFilter)?.name ?? conceptFilter,182 [concepts, conceptFilter]183 );184185 const cardRef = useRef<HTMLButtonElement>(null);186187 return (188 <main className="px-4 sm:px-6 py-6 max-w-3xl mx-auto w-full">189 <div className="flex flex-wrap items-center justify-between gap-3 mb-5">190 <div>191 <h2 className="text-lg font-bold text-fg">Cartes mémoire</h2>192 <p className="text-[13px] text-muted">193 {conceptFilter ? <>Ciblage : <span className="font-medium text-fg">{conceptName}</span> · </> : null}194 Répétition espacée — notez honnêtement, l'algorithme fait le reste.195 </p>196 </div>197 <div className="flex gap-2">198 <Button variant="secondary" size="sm" onClick={() => { setModalMsg(null); setCreateOpen(true); }}>199 <Plus size={14} /> Créer200 </Button>201 <Button variant="gold" size="sm" onClick={() => { setModalMsg(null); setGenerateOpen(true); }}>202 <Sparkles size={14} /> Générer par IA203 </Button>204 </div>205 </div>206207 {/* Stats */}208 {stats && (209 <div className="flex flex-wrap gap-1.5 mb-4">210 <Badge tone="neutral">{stats.total} carte{stats.total > 1 ? "s" : ""} au total</Badge>211 <Badge tone="green">{stats.learned} apprise{stats.learned > 1 ? "s" : ""}</Badge>212 <Badge tone="amber">{stats.suspended} suspendue{stats.suspended > 1 ? "s" : ""}</Badge>213 {initialCount > 0 && (214 <Badge tone="brand" className="ml-auto">Session : {Math.min(doneCount + 1, initialCount)}/{initialCount}</Badge>215 )}216 </div>217 )}218219 {error && <ErrorBanner message={error} className="mb-4" />}220221 {queue === null ? (222 <div className="space-y-3">223 <Skeleton className="h-64 w-full" />224 <Skeleton className="h-10 w-full" />225 </div>226 ) : !current ? (227 <EmptyState228 icon={<CheckCircle2 />}229 title={doneCount > 0 ? "Session terminée — bravo !" : "Tout est à jour !"}230 description={231 doneCount > 0232 ? `${doneCount} carte${doneCount > 1 ? "s" : ""} revue${doneCount > 1 ? "s" : ""}. Les prochaines échéances arriveront selon vos notes.`233 : "Aucune carte due pour l'instant. Revenez demain, ou créez / générez de nouvelles cartes pour apprendre davantage."234 }235 action={236 <div className="flex gap-2">237 <Button variant="secondary" size="sm" onClick={load}>Actualiser</Button>238 <Button variant="gold" size="sm" onClick={() => { setModalMsg(null); setGenerateOpen(true); }}>239 <Sparkles size={14} /> Générer par IA240 </Button>241 </div>242 }243 />244 ) : (245 <div className="space-y-4">246 {/* Métadonnées de la carte */}247 <div className="flex flex-wrap items-center gap-1.5">248 <Badge tone="brand">{TYPE_LABELS[current.type] ?? current.type}</Badge>249 {current.concept_name && <Badge tone="neutral">{current.concept_name}</Badge>}250 {current.reps == null && <Badge tone="gold">Nouvelle carte</Badge>}251 <div className="ml-auto flex items-center gap-1">252 <button253 title={favorites[current.id] ? "Retirer des favoris" : "Marquer comme favori"}254 aria-label="Favori"255 onClick={() => setFavorites((f) => ({ ...f, [current.id]: !f[current.id] }))}256 className={cn("p-1.5 rounded-md transition-colors", favorites[current.id] ? "text-gold-500" : "text-muted hover:text-gold-500")}257 >258 <Star size={16} className={favorites[current.id] ? "fill-gold-500" : ""} />259 </button>260 <button261 title="Suspendre cette carte (ne plus la proposer)"262 aria-label="Suspendre"263 onClick={suspendCurrent}264 disabled={posting}265 className="p-1.5 rounded-md text-muted hover:text-amber-500 transition-colors disabled:opacity-50"266 >267 <PauseCircle size={16} />268 </button>269 </div>270 </div>271272 {/* Carte retournable */}273 <button274 ref={cardRef}275 onClick={() => setRevealed((r) => !r)}276 className="w-full text-left [perspective:1200px] focus-visible:outline-2 rounded-2xl"277 aria-label={revealed ? "Verso de la carte — cliquer pour revoir le recto" : "Recto de la carte — cliquer ou appuyer sur Espace pour révéler"}278 >279 <div280 className={cn(281 "relative w-full min-h-64 transition-transform duration-500 [transform-style:preserve-3d]",282 revealed && "[transform:rotateY(180deg)]"283 )}284 >285 <Card className="absolute inset-0 p-6 flex flex-col [backface-visibility:hidden] overflow-y-auto">286 <span className="text-[11px] font-semibold text-muted uppercase tracking-wider mb-3">Recto</span>287 <div className="flex-1 flex items-center">288 <div className="w-full"><Markdown content={current.front} /></div>289 </div>290 <p className="text-[11.5px] text-muted mt-4 text-center">Cliquez ou appuyez sur Espace pour révéler la réponse</p>291 </Card>292 <Card className="absolute inset-0 p-6 flex flex-col [backface-visibility:hidden] [transform:rotateY(180deg)] overflow-y-auto border-brand-300 dark:border-brand-700">293 <span className="text-[11px] font-semibold text-brand-600 dark:text-brand-300 uppercase tracking-wider mb-3">Verso</span>294 <div className="flex-1 flex items-center">295 <div className="w-full"><Markdown content={current.back} /></div>296 </div>297 </Card>298 </div>299 </button>300301 {/* Boutons de qualité */}302 {revealed ? (303 <div className="grid grid-cols-4 gap-2" role="group" aria-label="Évaluer votre réponse">304 {QUALITIES.map((b) => (305 <button306 key={b.q}307 onClick={() => rate(b.q)}308 disabled={posting}309 title={`${b.hint} (touche ${b.key})`}310 className={cn(311 "h-12 rounded-xl text-sm font-semibold transition-colors disabled:opacity-60 flex flex-col items-center justify-center gap-0",312 b.cls313 )}314 >315 {posting ? <Spinner /> : (316 <>317 <span>{b.label}</span>318 <span className="text-[10px] font-normal opacity-80">{b.key}</span>319 </>320 )}321 </button>322 ))}323 </div>324 ) : (325 <Button variant="secondary" className="w-full justify-center" onClick={() => setRevealed(true)}>326 Révéler la réponse (Espace)327 </Button>328 )}329 </div>330 )}331332 {/* Modale : créer une carte */}333 <Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Créer une carte">334 <form onSubmit={submitCreate} className="space-y-3.5">335 <div className="grid grid-cols-2 gap-3">336 <div>337 <Label htmlFor="fc-concept">Concept (optionnel)</Label>338 <select339 id="fc-concept"340 value={form.conceptSlug}341 onChange={(e) => setForm((f) => ({ ...f, conceptSlug: e.target.value }))}342 className="w-full h-10 px-3 rounded-lg bg-card border border-app text-sm text-fg outline-none focus:border-brand-400"343 >344 <option value="">— Aucun —</option>345 {concepts.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}346 </select>347 </div>348 <div>349 <Label htmlFor="fc-type">Type</Label>350 <select351 id="fc-type"352 value={form.type}353 onChange={(e) => setForm((f) => ({ ...f, type: e.target.value }))}354 className="w-full h-10 px-3 rounded-lg bg-card border border-app text-sm text-fg outline-none focus:border-brand-400"355 >356 {Object.entries(TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}357 </select>358 </div>359 </div>360 <div>361 <Label htmlFor="fc-front">Recto (question) — Markdown et LaTeX acceptés</Label>362 <Textarea id="fc-front" rows={3} required minLength={3} maxLength={2000}363 value={form.front} onChange={(e) => setForm((f) => ({ ...f, front: e.target.value }))}364 placeholder="Ex. : Quelle est la formule du taux global d'actualisation ?" />365 </div>366 <div>367 <Label htmlFor="fc-back">Verso (réponse)</Label>368 <Textarea id="fc-back" rows={4} required maxLength={4000}369 value={form.back} onChange={(e) => setForm((f) => ({ ...f, back: e.target.value }))}370 placeholder="La réponse, avec formules $...$ au besoin." />371 </div>372 {modalMsg && <ErrorBanner message={modalMsg} />}373 <Button type="submit" disabled={modalBusy} className="w-full justify-center">374 {modalBusy ? <Spinner /> : "Créer la carte"}375 </Button>376 </form>377 </Modal>378379 {/* Modale : génération IA */}380 <Modal open={generateOpen} onClose={() => setGenerateOpen(false)} title="Générer des cartes par IA">381 <form onSubmit={submitGenerate} className="space-y-3.5">382 <p className="text-[13px] text-muted">383 Les cartes sont produites à partir du matériel officiel du cours, pour vous seulement. Vérifiez-les avant de vous y fier.384 </p>385 <div>386 <Label htmlFor="gen-concept">Concept ciblé</Label>387 <select388 id="gen-concept"389 value={genForm.conceptSlug}390 onChange={(e) => setGenForm((f) => ({ ...f, conceptSlug: e.target.value }))}391 required392 className="w-full h-10 px-3 rounded-lg bg-card border border-app text-sm text-fg outline-none focus:border-brand-400"393 >394 <option value="">— Choisir un concept —</option>395 {concepts.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}396 </select>397 </div>398 <div>399 <Label htmlFor="gen-count">Nombre de cartes : {genForm.count}</Label>400 <input401 id="gen-count" type="range" min={2} max={10} step={1}402 value={genForm.count}403 onChange={(e) => setGenForm((f) => ({ ...f, count: Number(e.target.value) }))}404 className="w-full accent-[#1d64b0]"405 />406 </div>407 {modalMsg && (408 modalMsg.includes("créée")409 ? <p className="text-sm text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/25 rounded-xl px-4 py-3">{modalMsg}</p>410 : <ErrorBanner message={modalMsg} />411 )}412 <Button type="submit" variant="gold" disabled={modalBusy} className="w-full justify-center">413 {modalBusy ? <><Spinner /> Génération en cours…</> : "Générer"}414 </Button>415 </form>416 </Modal>417 </main>418 );419}420