SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
14 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
5.5 KB · 135 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { usePathname, useRouter } from "next/navigation";4import { PanelLeft } from "lucide-react";5import { useApp } from "./store";6import { Sidebar } from "./sidebar";7import { CommandPalette } from "./command-palette";8import { BottomNav } from "./bottom-nav";9import { Button } from "@/components/ui/button";10import { useIsMobile, useKeyboardInset, useLocalStorage, usePrefersReducedMotion, useSwipe } from "@/lib/client/hooks";11import { cn } from "@/lib/utils";1213const LS_COLLAPSED = "polyllm:sidebar-collapsed";1415/**16 * Application shell.17 *18 * Desktop (≥ md): persistent left sidebar (collapsible, ⌘B) + content.19 * Phone: full-viewport content (`.h-app` tracks the keyboard), a slide-over conversation drawer20 *        (edge-swipe to open, swipe left / tap scrim to close) and a persistent bottom navigation.21 */22export function AppShell({ children }: { children: React.ReactNode }) {23  const { sidebarOpen, setSidebarOpen, setPaletteOpen, paletteOpen } = useApp();24  const [collapsed, setCollapsed] = useLocalStorage<boolean>(LS_COLLAPSED, false);25  const router = useRouter();26  const pathname = usePathname();27  const isMobile = useIsMobile();28  useKeyboardInset();2930  // Close the drawer on navigation.31  React.useEffect(() => {32    setSidebarOpen(false);33  }, [pathname, setSidebarOpen]);3435  const toggleCollapsed = React.useCallback(() => setCollapsed((c) => !c), [setCollapsed]);3637  // Global shortcuts38  React.useEffect(() => {39    const onKey = (e: KeyboardEvent) => {40      const mod = e.metaKey || e.ctrlKey;41      const target = e.target as HTMLElement | null;42      const typing = target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);43      if (mod && e.key.toLowerCase() === "k") {44        e.preventDefault();45        setPaletteOpen(!paletteOpen);46      } else if (mod && e.key.toLowerCase() === "n" && !e.shiftKey) {47        e.preventDefault();48        router.push("/app/chat");49      } else if (mod && e.key === "b") {50        e.preventDefault();51        toggleCollapsed();52      } else if (!mod && !typing && e.key === "?" ) {53        e.preventDefault();54        setPaletteOpen(true);55      }56    };57    window.addEventListener("keydown", onKey);58    return () => window.removeEventListener("keydown", onKey);59  }, [paletteOpen, router, setPaletteOpen, toggleCollapsed]);6061  // Edge swipe (from the left 24px) opens the drawer on phones.62  const contentRef = React.useRef<HTMLDivElement>(null);63  const openDrawer = React.useCallback(() => setSidebarOpen(true), [setSidebarOpen]);64  useSwipe(contentRef, { onSwipeRight: openDrawer, edge: 28, disabled: !isMobile || sidebarOpen, ignoreSelector: ".snap-row, [data-no-edge-swipe]" });6566  // Hide the bottom nav on full-screen surfaces (e.g. share preview) via a data attribute set by pages.67  const showBottomNav = isMobile && !pathname.startsWith("/app/onboarding");6869  return (70    <div className="flex h-app w-full overflow-hidden bg-bg">71      {/* Desktop sidebar */}72      <aside className={cn("hidden md:flex h-full shrink-0 flex-col border-r border-border bg-bg-subtle transition-[width] duration-200 ease-out", collapsed ? "w-0 overflow-hidden border-r-0" : "w-[276px]")}>73        <Sidebar onCollapse={toggleCollapsed} />74      </aside>7576      {/* Mobile drawer */}77      <MobileDrawer open={sidebarOpen && isMobile} onClose={() => setSidebarOpen(false)} />7879      <div ref={contentRef} className={cn("relative flex min-w-0 flex-1 flex-col", showBottomNav && "pb-[calc(var(--bottom-nav-h)+var(--sab))] [html[data-keyboard]_&]:pb-0")}>80        {collapsed ? (81          <Button variant="ghost" size="icon-sm" className="absolute left-2 top-2 z-20 hidden md:inline-flex" onClick={toggleCollapsed} aria-label="Show sidebar">82            <PanelLeft />83          </Button>84        ) : null}85        {children}86      </div>8788      {showBottomNav ? <BottomNav /> : null}89      <CommandPalette />90    </div>91  );92}9394function MobileDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {95  const reduced = usePrefersReducedMotion();96  const [render, setRender] = React.useState(open);97  const [closing, setClosing] = React.useState(false);98  const panelRef = React.useRef<HTMLDivElement>(null);99100  React.useEffect(() => {101    if (open) {102      // eslint-disable-next-line react-hooks/set-state-in-effect103      setRender(true);104      setClosing(false);105    } else if (render) {106      setClosing(true);107      const t = setTimeout(() => {108        setRender(false);109        setClosing(false);110      }, reduced ? 0 : 200);111      return () => clearTimeout(t);112    }113  }, [open, render, reduced]);114115  useSwipe(panelRef, { onSwipeLeft: onClose, disabled: !open });116117  // Lock body scroll and close on Escape while open.118  React.useEffect(() => {119    if (!open) return;120    const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();121    window.addEventListener("keydown", onKey);122    return () => window.removeEventListener("keydown", onKey);123  }, [open, onClose]);124125  if (!render) return null;126  return (127    <div className="fixed inset-0 z-40 md:hidden" role="dialog" aria-modal="true" aria-label="Conversations">128      <div className={cn("absolute inset-0 bg-black/45 backdrop-blur-[2px]", closing ? "animate-fade-out" : "animate-fade-in")} onClick={onClose} />129      <div ref={panelRef} className={cn("absolute inset-y-0 left-0 flex w-[min(86vw,340px)] flex-col bg-bg-subtle shadow-lg pt-[var(--sat)] pb-[var(--sab)]", closing ? "animate-drawer-out" : "animate-drawer-in")}>130        <Sidebar onCollapse={onClose} mobile />131      </div>132    </div>133  );134}135