);
}
// 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. */}
}>