TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Visionneuse interactive des diapositives : navigation par séance et par diapo,3// clavier ←/→, recherche plein texte, boîtes sémantiques rendues en encadrés,4// PDF original, et « poser une question sur cette diapo » directement dans le chat.5import Link from "next/link";6import { useCallback, useEffect, useMemo, useRef, useState } from "react";7import { useRouter, useSearchParams } from "next/navigation";8import {9 ChevronLeft, ChevronRight, FileDown, List, MessageSquareText, Presentation, Search, X,10} from "lucide-react";11import { Badge, Button, Card, Skeleton, cn } from "@/components/ui";12import { Markdown } from "@/components/chat/markdown";13import { ErrorBanner } from "./shared";1415type Deck = { week: number; title: string; slides: number; sections: number };16type Slide = { ref_number: number; title: string; section_title: string; display_content: string; box_types: string };1718const CALLOUTS: Record<string, { label: string; cls: string }> = {19 "Définition": { label: "Définition", cls: "border-brand-500 bg-brand-50 dark:bg-brand-900/30" },20 "Concept": { label: "Concept", cls: "border-brand-500 bg-brand-50 dark:bg-brand-900/30" },21 "Important": { label: "Important", cls: "border-red-500 bg-red-50 dark:bg-red-950/30" },22 "Attention": { label: "Attention", cls: "border-red-500 bg-red-50 dark:bg-red-950/30" },23 "Exemple": { label: "Exemple", cls: "border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30" },24 "Note": { label: "Note", cls: "border-gold-500 bg-gold-500/8 dark:bg-gold-500/10" },25 "Question éclair": { label: "Question éclair", cls: "border-violet-500 bg-violet-50 dark:bg-violet-950/30" },26 "Formule": { label: "Formule", cls: "border-brand-400 bg-surface-1 dark:bg-brand-950/40" },27 "Calcul": { label: "Calcul", cls: "border-brand-400 bg-surface-1 dark:bg-brand-950/40" },28 "Données": { label: "Données", cls: "border-slate-400 bg-surface-1 dark:bg-brand-950/40" },29 "Rappel": { label: "Rappel", cls: "border-gold-500 bg-gold-500/8" },30 "Astuce": { label: "Astuce", cls: "border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30" },31 "Conseil": { label: "Conseil", cls: "border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30" },32 "Information": { label: "Information", cls: "border-brand-400 bg-brand-50 dark:bg-brand-900/30" },33 "Résultat": { label: "Résultat", cls: "border-emerald-600 bg-emerald-50 dark:bg-emerald-950/30" },34 "Énoncé": { label: "Énoncé", cls: "border-slate-400 bg-surface-1" },35 "Travail demandé": { label: "Travail demandé", cls: "border-violet-500 bg-violet-50 dark:bg-violet-950/30" },36 "Réconciliation": { label: "Réconciliation", cls: "border-gold-600 bg-gold-500/8" },37};38const CALLOUT_RE = new RegExp(`^(${Object.keys(CALLOUTS).join("|")})(?:\\s*—\\s*([^:\\n]{0,120}?))?\\s*:\\s*`);3940/** Convertit le texte aplati d'une diapo en blocs affichables. */41function parseBlocks(content: string): { kind: "callout" | "text" | "table"; label?: string; title?: string; body: string }[] {42 const out: { kind: "callout" | "text" | "table"; label?: string; title?: string; body: string }[] = [];43 for (const rawBlock of content.split(/\n\n+/)) {44 const block = rawBlock.trim();45 if (!block) continue;46 const m = block.match(CALLOUT_RE);47 const lines = block.split("\n");48 const pipeLines = lines.filter((l) => l.includes(" | "));49 if (m) {50 out.push({ kind: "callout", label: m[1], title: m[2]?.trim(), body: mdify(block.slice(m[0].length)) });51 } else if (pipeLines.length >= 2 && pipeLines.length >= lines.length - 1) {52 out.push({ kind: "table", body: block });53 } else {54 out.push({ kind: "text", body: mdify(block) });55 }56 }57 return out;58}59function mdify(s: string): string {60 return s61 .split("\n")62 .map((l) => (l.trim().startsWith("• ") ? l.replace(/^\s*• /, "- ") : l))63 .join("\n");64}65function TableBlock({ body }: { body: string }) {66 const rows = body.split("\n").filter((l) => l.trim());67 return (68 <div className="overflow-x-auto rounded-lg border border-app">69 <table className="w-full text-[13px]">70 <tbody>71 {rows.map((r, i) => (72 <tr key={i} className={i === 0 ? "bg-surface-2 dark:bg-brand-900/50 font-semibold" : i % 2 ? "bg-surface-1 dark:bg-brand-950/30" : ""}>73 {r.split(" | ").map((c, j) => (74 <td key={j} className="px-3 py-1.5 border-b border-app text-fg whitespace-nowrap sm:whitespace-normal">{c}</td>75 ))}76 </tr>77 ))}78 </tbody>79 </table>80 </div>81 );82}8384export function SlidesViewer({ course }: { course: string }) {85 const router = useRouter();86 const search = useSearchParams();87 const [decks, setDecks] = useState<Deck[] | null>(null);88 const [week, setWeek] = useState<number | null>(null);89 const [slides, setSlides] = useState<Slide[] | null>(null);90 const [index, setIndex] = useState(0);91 const [error, setError] = useState<string | null>(null);92 const [panelOpen, setPanelOpen] = useState(false);93 const [query, setQuery] = useState("");94 const mainRef = useRef<HTMLDivElement>(null);95 const courseCode = course.toUpperCase();9697 useEffect(() => {98 fetch(`/api/slides/${course}`)99 .then((r) => (r.ok ? r.json() : Promise.reject(new Error("Chargement impossible"))))100 .then((d) => {101 setDecks(d.decks);102 const w = parseInt(search.get("semaine") ?? "", 10);103 if (d.decks.length) setWeek(Number.isInteger(w) && d.decks.some((x: Deck) => x.week === w) ? w : d.decks[0].week);104 })105 .catch((e) => setError(e.message));106 // eslint-disable-next-line react-hooks/exhaustive-deps107 }, [course]);108109 useEffect(() => {110 if (week == null) return;111 setSlides(null);112 setIndex(0);113 fetch(`/api/slides/${course}/${week}`)114 .then((r) => (r.ok ? r.json() : Promise.reject(new Error("Séance introuvable"))))115 .then((d) => setSlides(d.slides))116 .catch((e) => setError(e.message));117 }, [course, week]);118119 const go = useCallback(120 (delta: number) => {121 if (!slides) return;122 setIndex((i) => Math.min(slides.length - 1, Math.max(0, i + delta)));123 mainRef.current?.scrollTo({ top: 0 });124 },125 [slides]126 );127128 useEffect(() => {129 const onKey = (e: KeyboardEvent) => {130 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;131 if (e.key === "ArrowRight" || e.key === " ") { e.preventDefault(); go(1); }132 if (e.key === "ArrowLeft") { e.preventDefault(); go(-1); }133 if (e.key === "Escape") setPanelOpen(false);134 };135 window.addEventListener("keydown", onKey);136 return () => window.removeEventListener("keydown", onKey);137 }, [go]);138139 const results = useMemo(() => {140 if (!slides || query.trim().length < 2) return null;141 const q = query.toLowerCase();142 return slides143 .map((s, i) => ({ s, i }))144 .filter(({ s }) => s.title.toLowerCase().includes(q) || s.display_content.toLowerCase().includes(q))145 .slice(0, 30);146 }, [slides, query]);147148 if (error) return <main className="px-4 py-6 max-w-5xl mx-auto w-full"><ErrorBanner message={error} /></main>;149 if (!decks) {150 return (151 <main className="px-4 sm:px-6 py-6 max-w-6xl mx-auto w-full space-y-4">152 <Skeleton className="h-10 w-full max-w-lg" />153 <Skeleton className="h-[26rem] w-full" />154 </main>155 );156 }157 if (!decks.length) {158 return (159 <main className="px-4 py-10 text-center text-muted">160 Aucune diapositive indexée pour ce cours — demandez au professeur de lancer l'ingestion.161 </main>162 );163 }164165 const slide = slides?.[index];166 const blocks = slide ? parseBlocks(slide.display_content) : [];167 const deck = decks.find((d) => d.week === week);168169 return (170 <main className="flex flex-col h-[calc(100dvh-10.5rem)] md:h-[calc(100dvh-7rem)] max-w-7xl mx-auto w-full px-2 sm:px-6 py-2 sm:py-4">171 {/* Barre de la visionneuse */}172 <div className="flex items-center gap-2 pb-2 sm:pb-3">173 <select174 value={week ?? ""}175 onChange={(e) => { const w = parseInt(e.target.value, 10); setWeek(w); router.replace(`?semaine=${w}`, { scroll: false }); }}176 aria-label="Séance"177 className="h-9 rounded-lg border border-app bg-card text-[13px] font-semibold text-fg px-2.5 outline-none focus:border-brand-400 min-w-0 max-w-[55vw] sm:max-w-xs truncate"178 >179 {decks.map((d) => (180 <option key={d.week} value={d.week}>181 S{d.week} — {d.title.replace(/^Séance \d+\s*(—\s*)?/, "") || "Séance"} ({d.slides})182 </option>183 ))}184 </select>185 <button186 onClick={() => setPanelOpen((v) => !v)}187 aria-label="Sommaire et recherche"188 aria-expanded={panelOpen}189 className={cn("h-9 px-2.5 inline-flex items-center gap-1.5 rounded-lg border text-[13px] font-medium transition-colors",190 panelOpen ? "border-brand-500 bg-brand-50 dark:bg-brand-900/40 text-brand-700 dark:text-brand-200" : "border-app bg-card text-muted hover:text-fg")}191 >192 <List size={15} /> <span className="hidden sm:inline">Sommaire</span>193 </button>194 <div className="ml-auto flex items-center gap-1.5">195 {slide && (196 <Link197 href={`/chat?course=${courseCode}&q=${encodeURIComponent(`Explique-moi la diapositive ${slide.ref_number} de la séance ${week} (« ${slide.title} »).`)}`}198 className="h-9 px-2.5 inline-flex items-center gap-1.5 rounded-lg border border-app bg-card text-[13px] font-medium text-muted hover:text-fg hover:border-brand-400 transition-colors"199 title="Poser une question sur cette diapositive dans le chat"200 >201 <MessageSquareText size={14} /> <span className="hidden sm:inline">Demander au chat</span>202 </Link>203 )}204 <a205 href={`/api/slides/${course}/${week}?pdf=1`}206 target="_blank"207 rel="noopener"208 className="h-9 px-2.5 inline-flex items-center gap-1.5 rounded-lg border border-app bg-card text-[13px] font-medium text-muted hover:text-fg hover:border-brand-400 transition-colors"209 title="Ouvrir le PDF original de la séance"210 >211 <FileDown size={14} /> <span className="hidden sm:inline">PDF</span>212 </a>213 </div>214 </div>215216 <div className="flex-1 flex gap-3 min-h-0">217 {/* Panneau sommaire / recherche */}218 {panelOpen && (219 <aside className="absolute sm:relative z-30 inset-x-2 sm:inset-auto sm:w-72 shrink-0 bg-card border border-app rounded-xl shadow-lg sm:shadow-none flex flex-col max-h-[70dvh] sm:max-h-none">220 <div className="p-2.5 border-b border-app flex items-center gap-2">221 <Search size={14} className="text-muted shrink-0" />222 <input223 value={query}224 onChange={(e) => setQuery(e.target.value)}225 placeholder="Rechercher dans la séance…"226 className="w-full bg-transparent text-[13px] text-fg placeholder:text-muted outline-none"227 />228 <button onClick={() => setPanelOpen(false)} className="sm:hidden text-muted p-1" aria-label="Fermer"><X size={15} /></button>229 </div>230 <div className="flex-1 overflow-y-auto p-1.5">231 {(results ?? slides?.map((s, i) => ({ s, i })) ?? []).map(({ s, i }) => (232 <button233 key={i}234 onClick={() => { setIndex(i); setPanelOpen(window.innerWidth >= 640); }}235 className={cn(236 "w-full text-left px-2.5 py-1.5 rounded-md text-[12.5px] transition-colors flex items-baseline gap-2",237 i === index ? "bg-brand-100 dark:bg-brand-900/50 text-brand-800 dark:text-brand-200 font-semibold" : "text-muted hover:text-fg hover:bg-surface-2 dark:hover:bg-brand-900/30"238 )}239 >240 <span className="text-[11px] tabular-nums w-6 shrink-0 text-right opacity-70">{s.ref_number}</span>241 <span className="truncate">{s.title}</span>242 </button>243 ))}244 {results && results.length === 0 && <p className="text-[12.5px] text-muted text-center py-6">Aucun résultat.</p>}245 </div>246 </aside>247 )}248249 {/* Diapositive */}250 <div className="flex-1 flex flex-col min-w-0">251 <Card className="flex-1 overflow-hidden flex flex-col relative">252 {!slides ? (253 <div className="p-6 space-y-3">254 <Skeleton className="h-7 w-2/3" />255 <Skeleton className="h-4 w-full" />256 <Skeleton className="h-4 w-5/6" />257 <Skeleton className="h-24 w-full" />258 </div>259 ) : slide ? (260 <>261 <div className="px-4 sm:px-8 pt-4 sm:pt-6 pb-3 border-b border-app shrink-0">262 {slide.section_title && (263 <p className="text-[11px] font-bold uppercase tracking-widest text-gold-600 dark:text-gold-400 mb-1">{slide.section_title}</p>264 )}265 <h2 className="text-lg sm:text-2xl font-bold tracking-tight text-brand-800 dark:text-brand-200 text-balance">{slide.title}</h2>266 </div>267 <div ref={mainRef} className="flex-1 overflow-y-auto px-4 sm:px-8 py-4 sm:py-5 space-y-3.5">268 {blocks.map((b, i) =>269 b.kind === "callout" ? (270 <div key={i} className={cn("border-l-[3px] rounded-r-lg px-3.5 sm:px-4 py-2.5", CALLOUTS[b.label!]?.cls ?? "border-app bg-surface-1")}>271 <p className="text-[11px] font-bold uppercase tracking-wider text-fg/70 mb-1">272 {b.label}{b.title ? ` — ${b.title}` : ""}273 </p>274 <Markdown content={b.body} />275 </div>276 ) : b.kind === "table" ? (277 <TableBlock key={i} body={b.body} />278 ) : (279 <Markdown key={i} content={b.body} />280 )281 )}282 </div>283 {/* Pied : progression + navigation */}284 <div className="shrink-0 border-t border-app">285 <div className="h-1 bg-surface-2 dark:bg-brand-900/60">286 <div className="h-full bg-brand-600 transition-all duration-300" style={{ width: `${((index + 1) / slides.length) * 100}%` }} />287 </div>288 <div className="flex items-center justify-between px-3 sm:px-6 py-2">289 <Button variant="secondary" size="sm" onClick={() => go(-1)} disabled={index === 0} aria-label="Diapositive précédente">290 <ChevronLeft size={15} /> <span className="hidden sm:inline">Précédente</span>291 </Button>292 <span className="text-[12.5px] font-semibold text-muted tabular-nums inline-flex items-center gap-2">293 <Presentation size={13} className="hidden sm:block" />294 {slide.ref_number} / {slides.at(-1)?.ref_number ?? slides.length}295 <Badge tone="brand" className="hidden md:inline-flex">Séance {week}</Badge>296 </span>297 <Button variant="secondary" size="sm" onClick={() => go(1)} disabled={index >= slides.length - 1} aria-label="Diapositive suivante">298 <span className="hidden sm:inline">Suivante</span> <ChevronRight size={15} />299 </Button>300 </div>301 </div>302 </>303 ) : null}304 </Card>305 <p className="hidden sm:block text-center text-[11px] text-muted mt-1.5">306 ← → pour naviguer · {deck?.slides ?? 0} diapositives · contenu reconstruit depuis la source du cours — le PDF original reste disponible307 </p>308 </div>309 </div>310 </main>311 );312}313