SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
18.3 KB · 420 lines tsx
Raw Blame History
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