import { useEffect, useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; import { Cat, Globe, Sun, Moon, LogOut } from "lucide-react"; import { useTranslation } from "react-i18next"; import * as SelectPrimitive from "@radix-ui/react-select"; import { cn } from "@/lib/utils"; import { useTier } from "@/lib/tier"; import { type NavSection } from "@/lib/nav"; import { sectionsForView } from "@/lib/viewmode"; import { useViewMode } from "@/lib/viewmode-store"; import { Select, SelectContent, SelectItem } from "@/components/ui/select"; import { RoleSwitcher } from "@/components/RoleSwitcher"; import { useTheme } from "@/lib/theme"; import { api } from "@/lib/api"; function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) { 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 { i18n } = useTranslation(); return ( ); } function ThemeToggle() { const { theme, toggleTheme } = useTheme(); const { t } = useTranslation("common"); return ( ); } export function AppShell() { const { isAdmin } = useTier(); const { view } = useViewMode(); const { t, i18n } = useTranslation("navigation"); // Sections are derived from is_admin AND the chosen home: visibleSections drops // every admin-gated section for a non-admin (fail-closed — false while /me loads or // on failure), then the role-switcher's view narrows further to the home the admin // is currently viewing. sectionsForView can only ever narrow, never widen, so an // admin who steps down to the User-home sees a plain user's sidebar. const sections = sectionsForView(view, isAdmin); // 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")}
); }