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%
15.7 KB · 313 lines tsx
Raw Blame History
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