import { Link, useRouterState } from "@tanstack/react-router"; import { Activity, GaugeCircle, KeyRound, LibraryBig, MonitorPlay, MoreHorizontal, Puzzle, ScrollText, Server, Settings, } from "lucide-react"; import { motion, stagger } from "motion/react"; import { type ReactNode, useState } from "react"; import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins"; import { BrandMark } from "@/components/brand-mark"; import { Wordmark } from "@/components/wordmark"; import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n"; import { cn } from "@/lib/utils"; import { m } from "@/paraglide/messages"; const MLink = motion(Link); const NAV = [ { to: "/", icon: Activity, label: () => m.nav_dashboard() }, { to: "/host", icon: Server, label: () => m.nav_host() }, { to: "/displays", icon: MonitorPlay, label: () => m.nav_displays() }, { to: "/library", icon: LibraryBig, label: () => m.nav_library() }, { to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() }, { to: "/logs", icon: ScrollText, label: () => m.nav_logs() }, { to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() }, { to: "/plugins", icon: Puzzle, label: () => m.nav_plugins() }, { to: "/settings", icon: Settings, label: () => m.nav_settings() }, ] as const; // "/plugins" is the store's index route and "/plugins/" a plugin's own UI, so the store entry // only counts as active on an exact match — otherwise it would light up alongside the plugin's own // nav entry below. Same reason "/" is exact. const EXACT: readonly string[] = ["/", "/plugins"]; // On phones a flat 8-tab bar is too cramped, so the first four are pinned and the rest live behind a // "More" tab that opens a sheet above the bar. Keep it ≤ 5 slots including "More". const MOBILE_PRIMARY = NAV.slice(0, 4); const MOBILE_OVERFLOW = NAV.slice(4); export function AppShell({ children }: { children: ReactNode }) { // Read the locale so the whole shell re-renders on a language switch. useLocale(); return (
{/* Desktop sidebar (≥ sm). Sticky at viewport height: the page (body) scrolls with long content, but the sidebar stays pinned — the explicit h-dvh stops the flex stretch that would otherwise grow it (and push the language switcher) below the fold. overflow-y-auto lets the nav itself scroll on very short viewports. */}
{/* Mobile top bar (< sm): brand + language. The sidebar is hidden here. */}
{/* Mobile: a 16px side gutter (matching the top bar) so content isn't overly narrow; pb-24 leaves room for the fixed bottom nav. Roomier padding from sm up. */}
{children}
); } /** Desktop sidebar: the dynamic "Plugins" group, fed by the plugin directory. Renders nothing until * at least one plugin surfaces a UI — a host with no plugins sees zero extra chrome. */ function PluginNavSection() { const { data } = usePlugins(); const plugins = uiPlugins(data); if (plugins.length === 0) return null; return (

{m.nav_plugins()}

{plugins.map((p) => { const Icon = pluginIcon(p.ui?.icon); return ( {p.title} ); })}
); } /** Mobile bottom navigation (< sm): four pinned tabs + a "More" tab whose sheet holds the rest. */ function MobileNav() { const [moreOpen, setMoreOpen] = useState(false); const pathname = useRouterState({ select: (s) => s.location.pathname }); const { data } = usePlugins(); const plugins = uiPlugins(data); // Highlight "More" when the current route lives in the overflow — plugins included. const overflowActive = pathname.startsWith("/plugins/") || MOBILE_OVERFLOW.some( (n) => pathname === n.to || pathname.startsWith(`${n.to}/`), ); // Fixed two-line-tall label box so a 1- or 2-line label (labels vary by locale) keeps every icon // at the same height. const tab = "flex flex-1 flex-col items-center justify-center gap-1 px-0.5 py-2 text-muted-foreground transition-colors"; const lbl = "flex h-7 w-full items-center justify-center text-center text-[10px] leading-tight"; return ( <> {/* Tap-outside backdrop, under the bar (z-50) but over the page. */} {moreOpen && ( ); } function LanguageSwitcher() { const current = useLocale(); return ( // biome-ignore lint/a11y/useSemanticElements: an aria-labelled role="group" is the right pattern for this small control cluster — no single semantic element fits.
{locales.map((l: Locale) => ( ))}
); }