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