Repository Public MIT
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