TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Bibliothèque personnelle : filtres par type, aperçus, viewer Markdown en modale,3// copie, téléchargement et suppression.4import { useCallback, useEffect, useMemo, useState } from "react";5import { Bookmark, Copy, Download, Library, Trash2 } from "lucide-react";6import { PageHeader } from "@/components/app-shell";7import { Badge, Card, EmptyState, Modal, Skeleton, Tabs } from "@/components/ui";8import { Markdown } from "@/components/chat/markdown";9import { ErrorBanner, downloadText, fetchJson, fmtDate } from "./shared";1011type SavedItem = {12 id: number; kind: "note" | "answer" | "summary" | "quiz" | "plan";13 course_code: string | null; title: string; content: string; meta: string | null; created_at: string;14};1516const KIND_LABELS: Record<string, string> = {17 note: "Note", answer: "Réponse", summary: "Résumé", quiz: "Quiz", plan: "Plan",18};1920export function LibraryApp() {21 const [items, setItems] = useState<SavedItem[] | null>(null);22 const [filter, setFilter] = useState("tous");23 const [viewing, setViewing] = useState<SavedItem | null>(null);24 const [error, setError] = useState<string | null>(null);2526 const load = useCallback(() => {27 setError(null);28 fetchJson<{ items: SavedItem[] }>("/api/library")29 .then((d) => setItems(d.items))30 .catch((e) => { setItems([]); setError(e instanceof Error ? e.message : "Erreur de chargement."); });31 }, []);3233 useEffect(() => { load(); }, [load]);3435 async function remove(id: number) {36 if (!confirm("Supprimer définitivement cet élément ?")) return;37 setError(null);38 try {39 const res = await fetch("/api/library", {40 method: "DELETE",41 headers: { "Content-Type": "application/json" },42 body: JSON.stringify({ id }),43 });44 if (!res.ok) {45 const d = await res.json().catch(() => ({}));46 throw new Error((d as { error?: string }).error ?? `Erreur ${res.status}`);47 }48 setItems((xs) => (xs ? xs.filter((x) => x.id !== id) : xs));49 setViewing((v) => (v?.id === id ? null : v));50 } catch (e) {51 setError(e instanceof Error ? e.message : "Erreur de suppression.");52 }53 }5455 const kinds = useMemo(() => {56 const present = new Set((items ?? []).map((i) => i.kind));57 return ["tous", ...Object.keys(KIND_LABELS).filter((k) => present.has(k as SavedItem["kind"]))];58 }, [items]);5960 const filtered = useMemo(61 () => (items ?? []).filter((i) => filter === "tous" || i.kind === filter),62 [items, filter]63 );6465 return (66 <main className="px-4 sm:px-6 py-6 max-w-5xl mx-auto w-full">67 <PageHeader68 title="Bibliothèque"69 subtitle="Vos réponses sauvegardées, notes et résumés — retrouvables en tout temps."70 />7172 {error && <ErrorBanner message={error} className="mb-4" />}7374 {items === null ? (75 <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">76 <Skeleton className="h-40" /><Skeleton className="h-40" /><Skeleton className="h-40" />77 </div>78 ) : items.length === 0 ? (79 <EmptyState80 icon={<Bookmark />}81 title="Bibliothèque vide"82 description="Dans le chat, utilisez l'icône signet sous une réponse pour la conserver ici."83 />84 ) : (85 <>86 {kinds.length > 2 && (87 <Tabs88 className="mb-4"89 active={filter}90 onChange={setFilter}91 tabs={kinds.map((k) => ({ key: k, label: k === "tous" ? `Tous (${items.length})` : KIND_LABELS[k] ?? k }))}92 />93 )}94 {filtered.length === 0 ? (95 <EmptyState icon={<Library />} title="Rien dans ce filtre" />96 ) : (97 <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">98 {filtered.map((item) => (99 <Card key={item.id} className="p-4 flex flex-col animate-fade-up hover:border-brand-300 transition-colors">100 <button onClick={() => setViewing(item)} className="text-left flex-1">101 <div className="flex flex-wrap items-center gap-1.5 mb-2">102 <Badge tone="brand">{KIND_LABELS[item.kind] ?? item.kind}</Badge>103 {item.course_code && <Badge tone="neutral">{item.course_code}</Badge>}104 </div>105 <h3 className="text-sm font-semibold text-fg line-clamp-2 mb-1.5">{item.title}</h3>106 <p className="text-[12.5px] text-muted line-clamp-4 whitespace-pre-wrap">{item.content.slice(0, 320)}</p>107 </button>108 <div className="flex items-center justify-between mt-3 pt-3 border-t border-app">109 <span className="text-[11px] text-muted">{fmtDate(item.created_at)}</span>110 <button111 onClick={() => remove(item.id)}112 title="Supprimer" aria-label="Supprimer"113 className="p-1.5 rounded-md text-muted hover:text-red-500 transition-colors"114 >115 <Trash2 size={14} />116 </button>117 </div>118 </Card>119 ))}120 </div>121 )}122 </>123 )}124125 {/* Viewer */}126 <Modal open={!!viewing} onClose={() => setViewing(null)} title={viewing?.title} wide>127 {viewing && (128 <div>129 <div className="flex flex-wrap items-center gap-2 mb-4">130 <Badge tone="brand">{KIND_LABELS[viewing.kind] ?? viewing.kind}</Badge>131 {viewing.course_code && <Badge tone="neutral">{viewing.course_code}</Badge>}132 <span className="text-[11.5px] text-muted">{fmtDate(viewing.created_at)}</span>133 <div className="ml-auto flex gap-1">134 <button135 onClick={() => navigator.clipboard?.writeText(viewing.content)}136 title="Copier" aria-label="Copier"137 className="p-2 rounded-lg text-muted hover:text-fg hover:bg-surface-2 dark:hover:bg-brand-900/40"138 >139 <Copy size={15} />140 </button>141 <button142 onClick={() => downloadText(`${viewing.title.slice(0, 60).replace(/\s+/g, "-").toLowerCase()}.md`, `# ${viewing.title}\n\n${viewing.content}`)}143 title="Télécharger en .md" aria-label="Télécharger"144 className="p-2 rounded-lg text-muted hover:text-fg hover:bg-surface-2 dark:hover:bg-brand-900/40"145 >146 <Download size={15} />147 </button>148 <button149 onClick={() => remove(viewing.id)}150 title="Supprimer" aria-label="Supprimer"151 className="p-2 rounded-lg text-muted hover:text-red-500 hover:bg-surface-2 dark:hover:bg-brand-900/40"152 >153 <Trash2 size={15} />154 </button>155 </div>156 </div>157 <Markdown content={viewing.content} />158 </div>159 )}160 </Modal>161 </main>162 );163}164