Repository Public MIT
1import { useLocation } from "wouter";2import { useQuery, useQueryClient } from "@tanstack/react-query";3import {4 Plus, Sparkles, Compass, Users, BookOpen, LogOut, LogIn, PanelLeftClose, PanelLeft, Clock, MessageSquare,5} from "lucide-react";6import { formatDistanceToNow } from "date-fns";7import { fr } from "date-fns/locale";8import { Logo, LogoMark } from "@/components/brand/logo";9import { Button } from "@/components/ui/button";10import { ThemeToggle } from "@/components/theme-toggle";11import { ThemeColorPicker } from "@/components/theme-color-picker";12import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";13import { cn } from "@/lib/utils";1415export interface SessionListItem {16 sessionId: string;17 title: string;18 createdAt: string;19 updatedAt: string;20}2122export interface SidebarProps {23 collapsed?: boolean;24 onToggleCollapse?: () => void;25 onNewConversation?: () => void;26 currentSessionId?: string | null;27 onLoadSession?: (sessionId: string) => void;28 /** appelé après une navigation (ferme le tiroir mobile) */29 onNavigate?: () => void;30 className?: string;31}3233const NAV = [34 { href: "/", label: "Analyser", icon: Sparkles, match: (p: string) => p === "/" },35 { href: "/explore", label: "Marchés", icon: Compass, match: (p: string) => p.startsWith("/explore") },36 { href: "/showcase", label: "Showcase", icon: Users, match: (p: string) => p.startsWith("/showcase") || p.startsWith("/share") },37 { href: "/docs", label: "Documentation", icon: BookOpen, match: (p: string) => p.startsWith("/docs") },38];3940export function useCurrentUser() {41 const { data } = useQuery<{ user: { id: string; displayName: string; email?: string | null; emailVerified?: boolean; legacy?: boolean } | null }>({ queryKey: ["/api/auth/me"] });42 return data?.user ?? null;43}4445function initials(name: string) {46 return name.split(/\s+/).filter(Boolean).slice(0, 2).map((s) => s[0]?.toUpperCase()).join("") || "?";47}4849export function Sidebar({50 collapsed = false,51 onToggleCollapse,52 onNewConversation,53 currentSessionId,54 onLoadSession,55 onNavigate,56 className,57}: SidebarProps) {58 const [location, setLocation] = useLocation();59 const queryClient = useQueryClient();60 const user = useCurrentUser();6162 const { data: sessions = [], isLoading: sessionsLoading } = useQuery<SessionListItem[]>({63 queryKey: ["/api/sessions"],64 enabled: !!user,65 });6667 const go = (href: string) => {68 setLocation(href);69 onNavigate?.();70 };7172 const handleNew = () => {73 if (location !== "/") setLocation("/");74 onNewConversation?.();75 onNavigate?.();76 };7778 const handleSession = (id: string) => {79 if (onLoadSession && location === "/") onLoadSession(id);80 else setLocation(`/?session=${encodeURIComponent(id)}`);81 onNavigate?.();82 };8384 const handleLogout = async () => {85 await fetch("/api/auth/logout", { method: "POST" });86 queryClient.invalidateQueries({ queryKey: ["/api/auth/me"] });87 queryClient.invalidateQueries({ queryKey: ["/api/sessions"] });88 };8990 const Item = ({ href, label, icon: Icon, active }: { href: string; label: string; icon: typeof Sparkles; active: boolean }) => {91 const btn = (92 <button93 type="button"94 onClick={() => go(href)}95 aria-current={active ? "page" : undefined}96 className={cn(97 "group relative flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm font-medium transition-colors",98 collapsed && "justify-center px-0 h-10",99 active100 ? "bg-sidebar-accent text-foreground"101 : "text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/70"102 )}103 >104 {active && !collapsed && <span className="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-primary" />}105 <Icon className={cn("h-[18px] w-[18px] shrink-0", active ? "text-primary" : "text-muted-foreground group-hover:text-foreground")} />106 {!collapsed && <span className="truncate">{label}</span>}107 </button>108 );109 if (!collapsed) return btn;110 return (111 <Tooltip>112 <TooltipTrigger asChild>{btn}</TooltipTrigger>113 <TooltipContent side="right">{label}</TooltipContent>114 </Tooltip>115 );116 };117118 return (119 <aside120 className={cn(121 "flex h-full flex-col bg-sidebar text-sidebar-foreground border-r border-sidebar-border",122 collapsed ? "w-[68px]" : "w-[272px]",123 "transition-[width] duration-200 ease-out",124 className125 )}126 data-testid="sidebar"127 >128 {/* Brand */}129 <div className={cn("flex items-center h-14 px-3", collapsed ? "justify-center" : "justify-between pl-4")}>130 <button type="button" onClick={() => go("/")} className="flex items-center" aria-label="Accueil VQuant">131 {collapsed ? <LogoMark size={30} /> : <Logo size={30} />}132 </button>133 {!collapsed && onToggleCollapse && (134 <Button variant="ghost" size="icon" className="h-8 w-8 rounded-lg text-muted-foreground hidden lg:inline-flex" onClick={onToggleCollapse} aria-label="Replier le panneau">135 <PanelLeftClose className="h-4 w-4" />136 </Button>137 )}138 </div>139140 {/* New analysis */}141 <div className={cn("px-3 pb-2", collapsed && "px-2")}>142 {collapsed ? (143 <Tooltip>144 <TooltipTrigger asChild>145 <button146 type="button"147 onClick={handleNew}148 className="grid h-10 w-full place-items-center rounded-xl bg-brand text-white shadow-brand hover:brightness-105 transition"149 aria-label="Nouvelle analyse"150 data-testid="button-new-conversation"151 >152 <Plus className="h-5 w-5" />153 </button>154 </TooltipTrigger>155 <TooltipContent side="right">Nouvelle analyse</TooltipContent>156 </Tooltip>157 ) : (158 <button159 type="button"160 onClick={handleNew}161 data-testid="button-new-conversation"162 className="group flex w-full items-center gap-2.5 rounded-xl bg-brand px-3.5 py-2.5 text-sm font-semibold text-white shadow-brand transition hover:brightness-105 active:scale-[.99]"163 >164 <Plus className="h-[18px] w-[18px]" />165 Nouvelle analyse166 <span className="ml-auto hidden xl:inline-flex items-center gap-1 text-[10px] font-medium text-white/80">167 <kbd className="kbd !bg-white/15 !border-white/20 !text-white/90">⌘</kbd>168 <kbd className="kbd !bg-white/15 !border-white/20 !text-white/90">K</kbd>169 </span>170 </button>171 )}172 </div>173174 {/* Nav */}175 <nav className={cn("px-3 py-2 space-y-0.5", collapsed && "px-2")} aria-label="Navigation principale">176 {NAV.map((n) => (177 <Item key={n.href} href={n.href} label={n.label} icon={n.icon} active={n.match(location)} />178 ))}179 </nav>180181 {/* History */}182 <div className="flex-1 min-h-0 flex flex-col border-t border-sidebar-border/70 mt-1">183 {!collapsed && (184 <div className="flex items-center justify-between px-4 pt-3 pb-1.5">185 <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Historique</span>186 {user && sessions.length > 0 && (187 <span className="text-[11px] tnum text-muted-foreground">{sessions.length}</span>188 )}189 </div>190 )}191 <div className={cn("flex-1 min-h-0 overflow-y-auto px-2 pb-2", collapsed && "px-2 pt-2")}>192 {!user ? (193 collapsed ? (194 <Tooltip>195 <TooltipTrigger asChild>196 <button type="button" onClick={() => go("/auth")} className="grid h-10 w-full place-items-center rounded-xl text-muted-foreground hover:bg-sidebar-accent/70 hover:text-foreground" aria-label="Se connecter">197 <LogIn className="h-[18px] w-[18px]" />198 </button>199 </TooltipTrigger>200 <TooltipContent side="right">Se connecter pour garder l'historique</TooltipContent>201 </Tooltip>202 ) : (203 <div className="mx-1 mt-1 rounded-xl border border-dashed border-border bg-card/60 p-3">204 <div className="flex items-center gap-2 text-sm font-medium">205 <Clock className="h-4 w-4 text-primary" />206 Historique privé207 </div>208 <p className="mt-1 text-xs text-muted-foreground leading-snug">209 Connectez-vous pour retrouver vos analyses d'un appareil à l'autre.210 </p>211 <Button size="sm" variant="outline" className="mt-2.5 w-full rounded-lg" onClick={() => go("/auth")}>212 <LogIn className="h-3.5 w-3.5" /> Se connecter213 </Button>214 </div>215 )216 ) : sessionsLoading ? (217 <div className="space-y-2 px-1 pt-1">218 {[0, 1, 2, 3].map((i) => <div key={i} className="skeleton h-9" />)}219 </div>220 ) : sessions.length === 0 ? (221 !collapsed && (222 <div className="px-2 py-6 text-center">223 <MessageSquare className="mx-auto mb-2 h-6 w-6 text-muted-foreground/50" />224 <p className="text-xs text-muted-foreground">Aucune analyse pour l'instant.</p>225 </div>226 )227 ) : (228 <ul className="space-y-0.5">229 {sessions.map((s) => {230 const active = s.sessionId === currentSessionId;231 const when = formatDistanceToNow(new Date(s.updatedAt), { addSuffix: true, locale: fr });232 const row = (233 <button234 type="button"235 onClick={() => handleSession(s.sessionId)}236 data-testid={`session-${s.sessionId}`}237 className={cn(238 "group flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",239 collapsed && "justify-center px-0 py-2.5",240 active ? "bg-sidebar-accent" : "hover:bg-sidebar-accent/70"241 )}242 >243 <MessageSquare className={cn("mt-0.5 h-3.5 w-3.5 shrink-0", active ? "text-primary" : "text-muted-foreground")} />244 {!collapsed && (245 <span className="min-w-0 flex-1">246 <span className={cn("block truncate text-[13px] leading-snug", active ? "text-foreground font-medium" : "text-foreground/85")}>{s.title || "Analyse"}</span>247 <span className="block text-[11px] text-muted-foreground">{when}</span>248 </span>249 )}250 </button>251 );252 return (253 <li key={s.sessionId}>254 {collapsed ? (255 <Tooltip>256 <TooltipTrigger asChild>{row}</TooltipTrigger>257 <TooltipContent side="right" className="max-w-[240px]">{s.title || "Analyse"}</TooltipContent>258 </Tooltip>259 ) : row}260 </li>261 );262 })}263 </ul>264 )}265 </div>266 </div>267268 {/* Footer */}269 <div className={cn("border-t border-sidebar-border/70 p-3", collapsed && "p-2")}>270 {collapsed ? (271 <div className="flex flex-col items-center gap-1">272 {onToggleCollapse && (273 <Button variant="ghost" size="icon" className="h-9 w-9 rounded-xl text-muted-foreground hidden lg:inline-flex" onClick={onToggleCollapse} aria-label="Déplier le panneau">274 <PanelLeft className="h-4 w-4" />275 </Button>276 )}277 <ThemeToggle />278 {user ? (279 <Tooltip>280 <TooltipTrigger asChild>281 <button type="button" onClick={() => go("/account")} className="grid h-9 w-9 place-items-center rounded-full bg-brand text-[11px] font-bold text-white" aria-label="Mon compte">282 {initials(user.displayName)}283 </button>284 </TooltipTrigger>285 <TooltipContent side="right">{user.displayName} · Mon compte</TooltipContent>286 </Tooltip>287 ) : null}288 </div>289 ) : (290 <>291 {user ? (292 <div className="flex items-center gap-2.5 rounded-xl px-1.5 py-1">293 <button type="button" onClick={() => go("/account")} className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg text-left hover:bg-sidebar-accent/70 -mx-1 px-1 py-0.5" aria-label="Mon compte">294 <span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-brand text-[11px] font-bold text-white">{initials(user.displayName)}</span>295 <span className="min-w-0 flex-1">296 <span className="block truncate text-sm font-medium">{user.displayName}</span>297 <span className={cn("block truncate text-[11px]", user.legacy ? "text-warning" : "text-muted-foreground")}>{user.legacy ? "Sécuriser le compte" : user.email ?? "Mon compte"}</span>298 </span>299 </button>300 <Tooltip>301 <TooltipTrigger asChild>302 <Button variant="ghost" size="icon" className="h-8 w-8 rounded-lg text-muted-foreground" onClick={handleLogout} aria-label="Déconnexion">303 <LogOut className="h-4 w-4" />304 </Button>305 </TooltipTrigger>306 <TooltipContent side="top">Déconnexion</TooltipContent>307 </Tooltip>308 </div>309 ) : (310 <button type="button" onClick={() => go("/auth")} className="flex w-full items-center gap-2.5 rounded-xl px-2 py-1.5 text-left hover:bg-sidebar-accent/70 transition-colors">311 <span className="grid h-8 w-8 shrink-0 place-items-center rounded-full border border-border bg-card text-muted-foreground"><LogIn className="h-4 w-4" /></span>312 <span className="min-w-0 flex-1">313 <span className="block text-sm font-medium">Mon compte</span>314 <span className="block text-[11px] text-muted-foreground">Créer un compte ou se connecter</span>315 </span>316 </button>317 )}318 <div className="mt-2 flex items-center justify-between px-1">319 <span className="text-[11px] text-muted-foreground">v3 · Desk</span>320 <div className="flex items-center gap-0.5">321 <ThemeColorPicker />322 <ThemeToggle />323 </div>324 </div>325 </>326 )}327 </div>328 </aside>329 );330}331