SPB Git

Repository Public MIT

sidebar.tsx 14.6 KB 331 lines tsx

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
Files main
Menu

Theme