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%
11.9 KB · 269 lines tsx
Raw Blame History
1"use client";2// Résumés : liste, génération (semaine / concept / préparation d'examen, 3 styles),3// viewer Markdown avec citations cliquables, copie et téléchargement .md.4import { useCallback, useEffect, useMemo, useState } from "react";5import { useSearchParams } from "next/navigation";6import { Check, Copy, Download, FileText, Sparkles } from "lucide-react";7import { Badge, Button, Card, EmptyState, Label, Modal, Skeleton, Spinner, cn } from "@/components/ui";8import { Markdown } from "@/components/chat/markdown";9import { CitationPanel, type Citation } from "@/components/chat/citation-panel";10import { ErrorBanner, downloadText, fetchJson, fmtDate, postJson } from "./shared";1112type Summary = {13  id: number; scope: "week" | "concept" | "exam-prep"; ref: string; title: string;14  content: string; citations: string; created_by: string; created_at: string;15};16type ConceptOpt = { slug: string; name: string };1718const SCOPE_LABELS: Record<Summary["scope"], string> = {19  week: "Semaine", concept: "Concept", "exam-prep": "Préparation d'examen",20};21const STYLES = [22  { key: "ultra-court", label: "Ultra-court", hint: "Une page maximum, l'essentiel en listes serrées." },23  { key: "detaille", label: "Détaillé", hint: "Structuré par sous-thèmes, exemples et erreurs fréquentes." },24  { key: "avec-formules", label: "Avec formules", hint: "Chaque formule en LaTeX, variables définies, mini-exemple chiffré." },25] as const;2627export function SummariesApp({ course }: { course: string }) {28  const searchParams = useSearchParams();29  const conceptParam = searchParams.get("concept");3031  const [summaries, setSummaries] = useState<Summary[] | null>(null);32  const [concepts, setConcepts] = useState<ConceptOpt[]>([]);33  const [selectedId, setSelectedId] = useState<number | null>(null);34  const [citation, setCitation] = useState<Citation | null>(null);35  const [error, setError] = useState<string | null>(null);36  const [copied, setCopied] = useState(false);3738  // Génération39  const [genOpen, setGenOpen] = useState(!!conceptParam);40  const [scope, setScope] = useState<Summary["scope"]>(conceptParam ? "concept" : "week");41  const [week, setWeek] = useState(1);42  const [conceptSlug, setConceptSlug] = useState(conceptParam ?? "");43  const [style, setStyle] = useState<(typeof STYLES)[number]["key"]>("detaille");44  const [genBusy, setGenBusy] = useState(false);45  const [genError, setGenError] = useState<string | null>(null);4647  const load = useCallback(() => {48    setError(null);49    fetchJson<{ summaries: Summary[] }>(`/api/learning/${course}/summaries`)50      .then((d) => setSummaries(d.summaries))51      .catch((e) => { setSummaries([]); setError(e instanceof Error ? e.message : "Erreur de chargement."); });52  }, [course]);5354  useEffect(() => { load(); }, [load]);5556  useEffect(() => {57    fetchJson<{ nodes: ConceptOpt[] }>(`/api/learning/${course}/concepts`)58      .then((d) => setConcepts(d.nodes))59      .catch(() => {});60  }, [course]);6162  const selected = useMemo(() => summaries?.find((s) => s.id === selectedId) ?? null, [summaries, selectedId]);63  const selectedCitations = useMemo<Citation[]>(() => {64    if (!selected) return [];65    try { return JSON.parse(selected.citations || "[]") as Citation[]; } catch { return []; }66  }, [selected]);6768  async function generate(e: React.FormEvent) {69    e.preventDefault();70    setGenBusy(true);71    setGenError(null);72    try {73      const body: Record<string, unknown> = { scope, style };74      if (scope === "week") body.week = week;75      if (scope === "concept") {76        if (!conceptSlug) { setGenError("Choisissez un concept."); setGenBusy(false); return; }77        body.conceptSlug = conceptSlug;78      }79      const d = await postJson<{ id: number }>(`/api/learning/${course}/summaries`, body);80      setGenOpen(false);81      load();82      setSelectedId(d.id);83    } catch (err) {84      setGenError(err instanceof Error ? err.message : "Erreur de génération.");85    } finally {86      setGenBusy(false);87    }88  }8990  function copySelected() {91    if (!selected) return;92    navigator.clipboard?.writeText(selected.content).then(() => {93      setCopied(true);94      setTimeout(() => setCopied(false), 1500);95    });96  }9798  return (99    <main className="px-4 sm:px-6 py-6 max-w-5xl mx-auto w-full">100      <div className="flex flex-wrap items-center justify-between gap-3 mb-5">101        <div>102          <h2 className="text-lg font-bold text-fg">Résumés</h2>103          <p className="text-[13px] text-muted">Générés depuis le matériel officiel du cours, avec les diapositives citées.</p>104        </div>105        <Button variant="gold" size="sm" onClick={() => { setGenError(null); setGenOpen(true); }}>106          <Sparkles size={14} /> Générer un résumé107        </Button>108      </div>109110      {error && <ErrorBanner message={error} className="mb-4" />}111112      {summaries === null ? (113        <div className="grid md:grid-cols-3 gap-4">114          <Skeleton className="h-40" /><Skeleton className="h-40" /><Skeleton className="h-40" />115        </div>116      ) : summaries.length === 0 ? (117        <EmptyState118          icon={<FileText />}119          title="Aucun résumé pour l'instant"120          description="Générez votre premier résumé : par semaine de cours, par concept, ou en préparation d'examen."121          action={122            <Button variant="gold" size="sm" onClick={() => { setGenError(null); setGenOpen(true); }}>123              <Sparkles size={14} /> Générer un résumé124            </Button>125          }126        />127      ) : (128        <div className="grid md:grid-cols-[280px_1fr] gap-5 items-start">129          {/* Liste */}130          <div className="space-y-2 md:max-h-[70vh] md:overflow-y-auto md:pr-1">131            {summaries.map((s) => (132              <button133                key={s.id}134                onClick={() => setSelectedId(s.id)}135                className={cn(136                  "w-full text-left border rounded-xl px-3.5 py-3 transition-colors",137                  selectedId === s.id138                    ? "border-brand-500 bg-brand-50 dark:bg-brand-900/40"139                    : "border-app bg-card hover:border-brand-300"140                )}141              >142                <p className="text-[13.5px] font-semibold text-fg line-clamp-2">{s.title}</p>143                <div className="flex flex-wrap items-center gap-1.5 mt-1.5">144                  <Badge tone="brand">{SCOPE_LABELS[s.scope] ?? s.scope}</Badge>145                  <span className="text-[11px] text-muted">{fmtDate(s.created_at)}</span>146                </div>147              </button>148            ))}149          </div>150151          {/* Viewer */}152          {selected ? (153            <Card className="p-5 sm:p-6 animate-fade-up">154              <div className="flex flex-wrap items-center gap-2 mb-4 pb-4 border-b border-app">155                <h3 className="font-bold text-fg flex-1 min-w-0">{selected.title}</h3>156                <Button variant="secondary" size="sm" onClick={copySelected} title="Copier le Markdown">157                  {copied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />} {copied ? "Copié" : "Copier"}158                </Button>159                <Button160                  variant="secondary" size="sm"161                  onClick={() => downloadText(`${selected.title.replace(/[^\p{L}\p{N} -]/gu, "").trim().replace(/\s+/g, "-").toLowerCase() || "resume"}.md`, `# ${selected.title}\n\n${selected.content}`)}162                  title="Télécharger en .md"163                >164                  <Download size={14} /> .md165                </Button>166              </div>167              <Markdown168                content={selected.content}169                onCitationClick={(index) => {170                  const c = selectedCitations.find((x) => x.index === index);171                  if (c) setCitation(c);172                }}173              />174              {selectedCitations.length > 0 && (175                <div className="mt-5 pt-4 border-t border-app flex flex-wrap items-center gap-1.5">176                  <span className="text-[11.5px] text-muted font-medium">Sources :</span>177                  {selectedCitations.map((c) => (178                    <button key={c.tag} className="citation-chip" onClick={() => setCitation(c)} title={c.refLabel}>179                      {c.tag}180                    </button>181                  ))}182                </div>183              )}184            </Card>185          ) : (186            <Card className="p-10 hidden md:flex items-center justify-center">187              <p className="text-sm text-muted">Choisissez un résumé dans la liste, ou générez-en un nouveau.</p>188            </Card>189          )}190        </div>191      )}192193      {/* Modale de génération */}194      <Modal open={genOpen} onClose={() => setGenOpen(false)} title="Générer un résumé">195        <form onSubmit={generate} className="space-y-4">196          <div>197            <Label>Portée</Label>198            <div className="grid grid-cols-3 gap-2" role="group" aria-label="Portée du résumé">199              {(Object.keys(SCOPE_LABELS) as Summary["scope"][]).map((s) => (200                <button201                  key={s}202                  type="button"203                  aria-pressed={scope === s}204                  onClick={() => setScope(s)}205                  className={cn(206                    "h-10 rounded-lg text-[12.5px] font-medium border transition-colors px-1",207                    scope === s ? "border-brand-500 bg-brand-50 dark:bg-brand-900/40 text-brand-700 dark:text-brand-300" : "border-app bg-card text-muted hover:text-fg"208                  )}209                >210                  {SCOPE_LABELS[s]}211                </button>212              ))}213            </div>214          </div>215          {scope === "week" && (216            <div>217              <Label htmlFor="sum-week">Semaine de cours</Label>218              <select219                id="sum-week" value={week} onChange={(e) => setWeek(Number(e.target.value))}220                className="w-full h-10 px-3 rounded-lg bg-card border border-app text-sm text-fg outline-none focus:border-brand-400"221              >222                {Array.from({ length: 14 }, (_, i) => i + 1).map((w) => <option key={w} value={w}>Semaine {w}</option>)}223              </select>224            </div>225          )}226          {scope === "concept" && (227            <div>228              <Label htmlFor="sum-concept">Concept</Label>229              <select230                id="sum-concept" value={conceptSlug} onChange={(e) => setConceptSlug(e.target.value)} required231                className="w-full h-10 px-3 rounded-lg bg-card border border-app text-sm text-fg outline-none focus:border-brand-400"232              >233                <option value="">— Choisir un concept —</option>234                {concepts.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}235              </select>236            </div>237          )}238          <div>239            <Label>Style</Label>240            <div className="space-y-2">241              {STYLES.map((s) => (242                <button243                  key={s.key}244                  type="button"245                  aria-pressed={style === s.key}246                  onClick={() => setStyle(s.key)}247                  className={cn(248                    "w-full text-left border rounded-xl px-3.5 py-2.5 transition-colors",249                    style === s.key ? "border-brand-500 bg-brand-50 dark:bg-brand-900/40" : "border-app bg-card hover:border-brand-300"250                  )}251                >252                  <p className="text-[13px] font-semibold text-fg">{s.label}</p>253                  <p className="text-[11.5px] text-muted mt-0.5">{s.hint}</p>254                </button>255              ))}256            </div>257          </div>258          {genError && <ErrorBanner message={genError} />}259          <Button type="submit" variant="gold" disabled={genBusy} className="w-full justify-center">260            {genBusy ? <><Spinner /> Génération en cours (peut prendre une minute)…</> : "Générer"}261          </Button>262        </form>263      </Modal>264265      <CitationPanel citation={citation} onClose={() => setCitation(null)} />266    </main>267  );268}269