TypeScript 98.3%
CSS 0.9%
Shell 0.7%
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