SPB Git

Repository Public MIT

app-shell.tsx 4.5 KB 132 lines tsx

1import { useCallback, useEffect, useState, type ReactNode } from "react";2import { useLocation } from "wouter";3import { Menu, Plus } from "lucide-react";4import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";5import { Button } from "@/components/ui/button";6import { Logo } from "@/components/brand/logo";7import { ThemeToggle } from "@/components/theme-toggle";8import { Sidebar } from "./sidebar";9import { cn } from "@/lib/utils";1011const COLLAPSE_KEY = "vquant-sidebar-collapsed";1213export interface AppShellProps {14  children: ReactNode;15  /** zone collée en bas du contenu (composeur) */16  footer?: ReactNode;17  /** bandeau au-dessus du contenu (ticker) */18  banner?: ReactNode;19  /** actions à droite de la barre mobile */20  topbarActions?: ReactNode;21  /** titre affiché dans la barre mobile (par défaut le logo) */22  topbarTitle?: ReactNode;23  onNewConversation?: () => void;24  currentSessionId?: string | null;25  onLoadSession?: (sessionId: string) => void;26  showNewOnTopbar?: boolean;27  contentClassName?: string;28  scrollRef?: React.Ref<HTMLDivElement>;29  testId?: string;30}3132export function AppShell({33  children,34  footer,35  banner,36  topbarActions,37  topbarTitle,38  onNewConversation,39  currentSessionId,40  onLoadSession,41  showNewOnTopbar,42  contentClassName,43  scrollRef,44  testId,45}: AppShellProps) {46  const [, setLocation] = useLocation();47  const [mobileOpen, setMobileOpen] = useState(false);48  const [collapsed, setCollapsed] = useState<boolean>(() => {49    try { return localStorage.getItem(COLLAPSE_KEY) === "1"; } catch { return false; }50  });5152  const toggleCollapse = useCallback(() => {53    setCollapsed((c) => {54      const next = !c;55      try { localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0"); } catch { /* ignore */ }56      return next;57    });58  }, []);5960  // Raccourci ⌘K / Ctrl+K → nouvelle analyse61  useEffect(() => {62    const onKey = (e: KeyboardEvent) => {63      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {64        e.preventDefault();65        setLocation("/");66        onNewConversation?.();67      }68    };69    window.addEventListener("keydown", onKey);70    return () => window.removeEventListener("keydown", onKey);71  }, [onNewConversation, setLocation]);7273  return (74    <div className="flex h-dvh w-full overflow-hidden bg-background text-foreground" data-testid={testId}>75      {/* Sidebar desktop */}76      <div className="hidden lg:flex shrink-0">77        <Sidebar78          collapsed={collapsed}79          onToggleCollapse={toggleCollapse}80          onNewConversation={onNewConversation}81          currentSessionId={currentSessionId}82          onLoadSession={onLoadSession}83        />84      </div>8586      {/* Sidebar mobile */}87      <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>88        <SheetContent side="left" className="w-[288px] max-w-[85vw] p-0 border-r-0 [&>button]:hidden">89          <SheetTitle className="sr-only">Navigation</SheetTitle>90          <Sidebar91            onNewConversation={onNewConversation}92            currentSessionId={currentSessionId}93            onLoadSession={onLoadSession}94            onNavigate={() => setMobileOpen(false)}95            className="w-full"96          />97        </SheetContent>98      </Sheet>99100      {/* Main column */}101      <div className="flex min-w-0 flex-1 flex-col">102        {/* Topbar mobile */}103        <header className="lg:hidden sticky top-0 z-40 flex h-14 shrink-0 items-center gap-2 border-b border-border bg-background/85 backdrop-blur-md px-3" data-testid="header-main">104          <Button variant="ghost" size="icon" className="h-9 w-9 rounded-xl" onClick={() => setMobileOpen(true)} aria-label="Ouvrir le menu">105            <Menu className="h-5 w-5" />106          </Button>107          <button type="button" onClick={() => setLocation("/")} className="flex items-center min-w-0" aria-label="Accueil">108            {topbarTitle ?? <Logo size={28} />}109          </button>110          <div className="ml-auto flex items-center gap-1">111            {topbarActions}112            {showNewOnTopbar && onNewConversation && (113              <Button variant="ghost" size="icon" className="h-9 w-9 rounded-xl" onClick={onNewConversation} aria-label="Nouvelle analyse">114                <Plus className="h-5 w-5" />115              </Button>116            )}117            <ThemeToggle />118          </div>119        </header>120121        {banner}122123        <div ref={scrollRef} className={cn("relative flex-1 min-h-0 overflow-y-auto", contentClassName)} data-testid="main-content">124          {children}125        </div>126127        {footer}128      </div>129    </div>130  );131}132
Files main
Menu

Theme