import { Suspense, useEffect, useState } from 'react' import { Link, Outlet, useMatchRoute, useNavigate, useRouterState } from '@tanstack/react-router' import { Toaster } from '@/components/ui/toast' import { useLogout, useMe } from '@/lib/auth' import { cn } from '@/lib/cn' // Top-level sections. `icon` is only used by the mobile bottom bar; the desktop // top bar shows labels alone. Settings is filtered to admins at render. const sections = [ { to: '/gardens', label: 'Gardens', icon: '🏡', adminOnly: false }, { to: '/plants', label: 'Plants', icon: '🌿', adminOnly: false }, { to: '/settings', label: 'Settings', icon: '⚙️', adminOnly: true }, ] as const type Section = (typeof sections)[number] // TanStack Router concatenates the base className with activeProps/inactiveProps, // so state-specific and conflicting utilities (text-muted vs text-fg) live in the // state props — never in the base — to avoid ambiguous overrides. const navLinkBase = 'rounded-md px-3 py-1.5 text-sm font-medium transition-colors' const navLinkActive = 'bg-border/60 text-fg' const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg' /** * Top-level chrome. Mobile-first: a slim top bar (brand + account) with the * section nav moved to a thumb-reachable bottom tab bar; the desktop breakpoint * (`md:`) restores the inline top nav. The editor is a full-screen context, so it * owns the bottom of the screen — the app bottom bar hides there (the brand link * is the way back to the gardens list), leaving exactly one bottom bar per route. */ export function AppShell() { const me = useMe() const user = me.data const matchRoute = useMatchRoute() // Full-screen canvas contexts own the bottom of the screen: the garden editor // and the public shared-garden view both render a 100dvh-8rem canvas, so a // signed-in viewer must not get the app bottom bar overlapping it. Fuzzy:false // so the '/gardens' list itself still shows the bar. const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false }) const inPublicGarden = !!matchRoute({ to: '/g/$token', fuzzy: false }) // A canvas route wants the whole width — the garden editor is squeezed by the // max-w-5xl reading measure the other pages use (#107). const canvasRoute = inEditor || inPublicGarden const showBottomNav = !!user && !canvasRoute const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin) return (
{/* The bar matches the content width below: constrained on reading pages, edge-to-edge on the canvas routes so the brand aligns with the editor. */}
{/* Boundary for the lazily-loaded routes (see router.tsx). */} Loading…

}>
{showBottomNav && }
) } /** Account control: a compact button that toggles a small sign-out popover. On * desktop the display name shows inline; on mobile it lives inside the popover. */ function AccountMenu({ displayName }: { displayName: string }) { const logout = useLogout() const navigate = useNavigate() const [open, setOpen] = useState(false) const pathname = useRouterState({ select: (s) => s.location.pathname }) // Close on any route change, so navigating (bottom nav, browser back) can't // leave the popover — and its full-screen backdrop — stuck open over the page. useEffect(() => setOpen(false), [pathname]) async function onLogout() { try { await logout.mutateAsync() await navigate({ to: '/login' }) } catch { // The logout request failed, so the session is still valid server-side. // Keep the popover OPEN so the button (now "Retry sign out", driven by // logout.isError) stays on screen — closing it would hide the only retry // affordance and pretend nothing went wrong. } } const initial = displayName.trim().charAt(0).toUpperCase() || '·' return (
{open && ( <> {/* Full-screen click-away backdrop; reliably closes on an outside tap without a document listener. */}
)} ) } /** Mobile bottom tab bar for the top-level sections (thumb zone, safe-area aware). */ function BottomNav({ sections }: { sections: ReadonlyArray
}) { return ( ) }