import { Suspense, useEffect, useState } from "react"; import { NavLink, Outlet, useLocation } from "react-router-dom"; import { Cat, Globe, Sun, Moon, LogOut, WifiOff, Menu, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import * as SelectPrimitive from "@radix-ui/react-select"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { cn } from "@/lib/utils"; import { useTier } from "@/lib/tier"; import { visibleSections, type NavSection } from "@/lib/nav"; import { Select, SelectContent, SelectItem } from "@/components/ui/select"; import { useTheme } from "@/lib/theme"; import { api, CONNECTION_EVENT, isConnectionLost } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { ConfigBanner, VersionBadge } from "@/components/RuntimeStatus"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { Loading } from "@/components/States"; function SectionGroup({ section, isFirst, onNavigate, }: { section: NavSection; isFirst: boolean; onNavigate?: () => void; }) { const { t } = useTranslation("navigation"); return (
{section.titleKey && (
{t(section.titleKey)}
)} {section.items.map((item) => ( cn( "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors", isActive ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-accent hover:text-foreground", ) } > {t(item.key)} ))}
); } // Shared visual baseline for every clickable icon button in the sidebar foot. // Styled with a subtle border and background to make them feel like tangible widgets, // resolving the flat "floating icons in empty space" visual issue. const FOOT_ICON_BTN = "flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border/40 bg-muted/20 text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none transition-all duration-150 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"; function UserStrip() { // The sidebar foot identifies the principal and exposes one action — sign out. // identity?.email is the only display-safe field (user_id is a UUID, role is // server-truth not display). While /me is loading or has failed we render a // muted placeholder rather than a broken row, so the strip never flashes empty. const { identity, refresh } = useTier(); const { t } = useTranslation("account"); const [signingOut, setSigningOut] = useState(false); // Mirrors Account.tsx#signOut: idempotent on the server; refresh() flips // unauthenticated → RequireAuth bounces to /login. No navigate() needed. async function signOut() { if (signingOut) return; setSigningOut(true); try { await api.logout(); } finally { await refresh(); } } return (
{identity?.email ?? —}
{identity?.role ?? "user"}
); } function LangToggle() { const { t, i18n } = useTranslation("common"); return ( ); } function ThemeToggle() { const { theme, toggleTheme } = useTheme(); const { t } = useTranslation("common"); return ( ); } // ConnectionBanner shows while API calls get no response at all (api.ts // CONNECTION_EVENT): the network is down, or Cloudflare Access sent the call to // its login page. It clears on the next call that gets through (the pages' // polling makes one soon); an expired Access sign-in only passes on a full page // load, hence the reload button. function ConnectionBanner() { const { t } = useTranslation("common"); const [lost, setLost] = useState(isConnectionLost); useEffect(() => { const onChange = (e: Event) => setLost(!(e as CustomEvent<{ ok: boolean }>).detail.ok); window.addEventListener(CONNECTION_EVENT, onChange); return () => window.removeEventListener(CONNECTION_EVENT, onChange); }, []); if (!lost) return null; return (
{t("connection_lost")}
); } // MobileNav is the phone-width way into every page: the sidebar is md-only, so // below md a menu button opens the same sections (and the same sign-out strip) // as a drawer from the left. Following a link closes it; so does a route change // from anywhere else (back button, a link inside the page). function MobileNav({ sections }: { sections: NavSection[] }) { const { t } = useTranslation("common"); const { pathname } = useLocation(); const [open, setOpen] = useState(false); useEffect(() => setOpen(false), [pathname]); return (
{t("brand_name")}
{/* The strip's sign-out button grows to a thumb-sized target below md. */}
); } export function AppShell() { const { isAdmin, isOwner } = useTier(); const { pathname } = useLocation(); const { t, i18n } = useTranslation("navigation"); // Sections are derived purely from is_admin and is_owner: User-Side always, // Admin-Side only for admins, Owner-Side only for the platform owner. Both // flags are fail-closed (false while /me loads or on failure), so admin and // owner sections appear only once identity is confirmed. const sections = visibleSections(isAdmin, isOwner); // Sync document metadata with the active language. useEffect(() => { document.documentElement.lang = i18n.language; document.title = t("common:page_title"); }, [i18n.language, t]); return (
{t("common:brand_name")}
{/* A crash on one page leaves the navigation usable; moving to another route clears it. Pages load as their own chunks, so the first visit to one shows a spinner here with the shell in place, and a chunk gone after a deploy lands in the boundary. */} }>
); }