spb/chat-spboucher-ai
Public
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