SPB Git
10.3 KB · 111 lines tsx
1"use client";2import Link from "next/link";3import { usePathname, useRouter } from "next/navigation";4import useSWR, { mutate } from "swr";5import { useMemo, useState } from "react";6import { Plus, Search, FolderKanban, Brain, BarChart3, Settings, FlaskConical, Columns2, Telescope, Pin, MoreHorizontal, Archive, Trash2, Pencil, FolderInput, PanelLeftClose, LogOut, ChevronDown, ChevronRight, Images } from "lucide-react";7import { fetcher, api } from "@/lib/client/fetcher";8import type { ConversationDTO, ProjectDTO } from "@/lib/client/types";9import { cn, Kbd, toast } from "./ui";10import { Logo } from "./logo";11import { setPrefs } from "@/lib/client/prefs";12import { resetDraft } from "@/lib/client/use-chat";1314const NAV = [15  { href: "/compare", label: "Comparer", icon: Columns2 },16  { href: "/research", label: "Recherche approfondie", icon: Telescope },17  { href: "/models", label: "Laboratoire de modèles", icon: FlaskConical },18  { href: "/memory", label: "Mémoire", icon: Brain },19  { href: "/images", label: "Images", icon: Images },20  { href: "/usage", label: "Coûts & usage", icon: BarChart3 },21  { href: "/settings", label: "Réglages", icon: Settings },22];2324function group(convs: ConversationDTO[]) {25  const now = new Date(); const d0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();26  const g: Record<string, ConversationDTO[]> = { "Épinglées": [], "Aujourd'hui": [], "Hier": [], "7 derniers jours": [], "30 derniers jours": [], "Plus ancien": [] };27  for (const c of convs) {28    if (c.pinned) { g["Épinglées"].push(c); continue; }29    const t = new Date(c.last_message_at ?? c.created_at).getTime();30    if (t >= d0) g["Aujourd'hui"].push(c); else if (t >= d0 - 86_400_000) g["Hier"].push(c); else if (t >= d0 - 7 * 86_400_000) g["7 derniers jours"].push(c); else if (t >= d0 - 30 * 86_400_000) g["30 derniers jours"].push(c); else g["Plus ancien"].push(c);31  }32  return Object.entries(g).filter(([, v]) => v.length);33}3435export function Sidebar({ onOpenPalette, onNavigate }: { onOpenPalette: () => void; onNavigate?: () => void }) {36  const pathname = usePathname(); const router = useRouter();37  const { data: convData } = useSWR<{ conversations: ConversationDTO[] }>("/api/conversations", fetcher, { refreshInterval: 30_000 });38  const { data: projData } = useSWR<{ projects: ProjectDTO[] }>("/api/projects", fetcher);39  const [projectsOpen, setProjectsOpen] = useState(true);40  const convs = useMemo(() => (convData?.conversations ?? []).filter(c => !c.project_id || true), [convData]);41  const groups = useMemo(() => group(convs), [convs]);42  const current = pathname.startsWith("/c/") ? pathname.slice(3) : null;4344  async function newChat() { resetDraft(); router.push("/"); onNavigate?.(); }45  async function logout() { await api("/api/auth/logout", { method: "POST" }); navigator.serviceWorker?.controller?.postMessage("logout"); location.href = "/login"; }4647  return (48    <div className="flex h-full flex-col">49      <div className="flex items-center justify-between px-3 pt-3 pb-2">50        <Link href="/" onClick={() => { resetDraft(); onNavigate?.(); }} className="flex items-center gap-2 rounded-lg px-1 py-1 hover:bg-surface-2"><Logo /><span className="text-[15px] font-semibold tracking-tight">SPBoucher <span className="text-accent-2">AI</span></span></Link>51        <button className="hidden md:inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted hover:bg-surface-2 hover:text-fg" title="Replier (⌘\)" onClick={() => setPrefs({ sidebarCollapsed: true })}><PanelLeftClose className="h-4 w-4" /></button>52      </div>53      <div className="px-3 pb-2 space-y-1">54        <button onClick={newChat} className="flex w-full items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium hover:border-border-strong hover:bg-surface-2 transition-colors"><Plus className="h-4 w-4 text-accent" /> <span className="truncate">Nouvelle conversation</span><span className="ml-auto hidden xl:inline-flex gap-0.5"><Kbd>⌘</Kbd><Kbd>⇧</Kbd><Kbd>O</Kbd></span></button>55        <button onClick={onOpenPalette} className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-fg-2 hover:bg-surface-2 hover:text-fg transition-colors"><Search className="h-4 w-4" /> Rechercher <span className="ml-auto hidden md:inline-flex gap-0.5"><Kbd>⌘</Kbd><Kbd>K</Kbd></span></button>56      </div>57      <nav className="px-3 pb-2">58        {NAV.map(n => <Link key={n.href} href={n.href} onClick={onNavigate} className={cn("flex items-center gap-2 rounded-lg px-3 py-1.5 text-[13px] transition-colors", pathname.startsWith(n.href) ? "bg-surface-2 text-fg" : "text-fg-2 hover:bg-surface-2 hover:text-fg")}><n.icon className="h-4 w-4" />{n.label}</Link>)}59      </nav>60      <div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3">61        {/* projects */}62        <div className="mt-1 flex items-center justify-between px-2 py-1">63          <button onClick={() => setProjectsOpen(o => !o)} className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted hover:text-fg">{projectsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} Projets</button>64          <Link href="/projects/new" onClick={onNavigate} className="rounded p-0.5 text-muted hover:text-fg" title="Nouveau projet"><Plus className="h-3.5 w-3.5" /></Link>65        </div>66        {projectsOpen && <div className="mb-2">{(projData?.projects ?? []).map(p => <Link key={p.id} href={`/projects/${p.id}`} onClick={onNavigate} className={cn("group flex items-center gap-2 rounded-lg px-3 py-1.5 text-[13px] transition-colors", pathname === `/projects/${p.id}` ? "bg-surface-2 text-fg" : "text-fg-2 hover:bg-surface-2 hover:text-fg")}><span className="h-2 w-2 rounded-full" style={{ background: p.color ?? "var(--accent)" }} /><span className="truncate">{p.name}</span><span className="ml-auto text-[11px] text-muted">{p.conversation_count ?? ""}</span></Link>)}67          {!projData?.projects?.length && <div className="px-3 py-1 text-[12px] text-muted">Aucun projet. <Link href="/projects/new" className="text-accent hover:underline">Créer</Link></div>}</div>}68        {/* history */}69        {groups.map(([label, items]) => (70          <div key={label} className="mt-2">71            <div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-muted">{label}</div>72            {items.map(c => <ConvItem key={c.id} c={c} active={current === c.id} onNavigate={onNavigate} projects={projData?.projects ?? []} />)}73          </div>74        ))}75        {!convs.length && convData && <div className="px-3 py-6 text-center text-[12px] text-muted">Aucune conversation pour l'instant.</div>}76      </div>77      <div className="border-t border-border px-3 py-2 flex items-center justify-between safe-b">78        <Link href="/settings" onClick={onNavigate} className="text-[12px] text-muted hover:text-fg">Réglages</Link>79        <button onClick={logout} className="inline-flex items-center gap-1 text-[12px] text-muted hover:text-danger"><LogOut className="h-3.5 w-3.5" /> Quitter</button>80      </div>81    </div>82  );83}8485function ConvItem({ c, active, onNavigate, projects }: { c: ConversationDTO; active: boolean; onNavigate?: () => void; projects: ProjectDTO[] }) {86  const [menu, setMenu] = useState(false); const [renaming, setRenaming] = useState(false); const [title, setTitle] = useState(c.title); const router = useRouter();87  async function patch(p: Record<string, unknown>) { await api(`/api/conversations/${c.id}`, { method: "PATCH", json: p }); void mutate("/api/conversations"); }88  async function del() { if (!confirm(`Supprimer « ${c.title} » ?`)) return; await api(`/api/conversations/${c.id}`, { method: "DELETE" }); void mutate("/api/conversations"); if (active) router.push("/"); toast("Conversation supprimée", "ok"); }89  return (90    <div className={cn("group relative flex items-center rounded-lg text-[13px] transition-colors", active ? "bg-surface-2 text-fg" : "text-fg-2 hover:bg-surface-2 hover:text-fg")}>91      {renaming ? <form className="flex-1 px-2 py-1" onSubmit={async e => { e.preventDefault(); await patch({ title }); setRenaming(false); }}><input autoFocus value={title} onChange={e => setTitle(e.target.value)} onBlur={() => setRenaming(false)} className="input !py-0.5 !px-1.5 text-[13px]" /></form>92        : <Link href={`/c/${c.id}`} onClick={onNavigate} className="flex min-w-0 flex-1 items-center gap-1.5 px-3 py-1.5">{c.pinned && <Pin className="h-3 w-3 shrink-0 text-accent-2" />}{c.mode === "research" && <Telescope className="h-3 w-3 shrink-0 text-accent" />}<span className="truncate">{c.title}</span></Link>}93      <button onClick={() => setMenu(m => !m)} className={cn("mr-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted hover:text-fg", menu ? "opacity-100" : "opacity-0 group-hover:opacity-100 focus:opacity-100")} aria-label="Options"><MoreHorizontal className="h-4 w-4" /></button>94      {menu && <><div className="fixed inset-0 z-30" onClick={() => setMenu(false)} /><div className="absolute right-0 top-full z-40 mt-1 w-52 anim-fade-up rounded-xl border border-border bg-bg-elev p-1 shadow-soft text-[13px]" onClick={() => setMenu(false)}>95        <MenuItem icon={Pencil} onClick={() => setRenaming(true)}>Renommer</MenuItem>96        <MenuItem icon={Pin} onClick={() => patch({ pinned: !c.pinned })}>{c.pinned ? "Désépingler" : "Épingler"}</MenuItem>97        <div className="my-1 border-t border-border" />98        <div className="px-2 py-1 text-[11px] uppercase tracking-wider text-muted">Déplacer vers</div>99        <MenuItem icon={FolderInput} onClick={() => patch({ projectId: null })}>Aucun projet</MenuItem>100        {projects.map(p => <MenuItem key={p.id} icon={FolderKanban} onClick={() => patch({ projectId: p.id })}>{p.name}</MenuItem>)}101        <div className="my-1 border-t border-border" />102        <MenuItem icon={Archive} onClick={() => patch({ archived: true })}>Archiver</MenuItem>103        <MenuItem icon={Trash2} onClick={del} danger>Supprimer</MenuItem>104      </div></>}105    </div>106  );107}108function MenuItem({ icon: Icon, children, onClick, danger }: { icon: React.ComponentType<{ className?: string }>; children: React.ReactNode; onClick: () => void; danger?: boolean }) {109  return <button onClick={onClick} className={cn("flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-surface-2", danger ? "text-danger" : "text-fg-2 hover:text-fg")}><Icon className="h-3.5 w-3.5" />{children}</button>;110}111
Files · main
Menu

Theme