diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 88442b2..4fe42f1 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -1,10 +1,14 @@ -import { Link, Outlet, useNavigate } from '@tanstack/react-router' +import { useState } from 'react' +import { Link, Outlet, useMatchRoute, useNavigate } from '@tanstack/react-router' import { Toaster } from '@/components/ui/toast' import { useLogout, useMe } from '@/lib/auth' -const navLinks = [ - { to: '/gardens', label: 'Gardens' }, - { to: '/plants', label: 'Plants' }, +// 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 // TanStack Router concatenates the base className with activeProps/inactiveProps, @@ -14,73 +18,56 @@ const navLinkBase = 'rounded-md px-3 py-1.5 text-sm font-medium transition-color const navLinkActive = 'bg-border/60 text-fg' const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg' -/** Top-level chrome: a sticky nav bar plus the routed page in an . */ +/** + * 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 logout = useLogout() - const navigate = useNavigate() const user = me.data + const matchRoute = useMatchRoute() - async function onLogout() { - try { - await logout.mutateAsync() - await navigate({ to: '/login' }) - } catch { - // The logout request failed, so the session is still valid server-side: - // leave the user where they are (the button re-enables for a retry) rather - // than pretending they're signed out. logout.isError drives the title below. - } - } + // The garden editor route ('/gardens/$gardenId') runs full-screen. Fuzzy:false + // so the '/gardens' list itself still shows the bottom bar. + const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false }) + const showBottomNav = !!user && !inEditor + + const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin) return (
-
+
-
+ {/* Bottom bar clearance on mobile so content isn't hidden behind it. */} +
+ {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) + + async function onLogout() { + try { + await logout.mutateAsync() + await navigate({ to: '/login' }) + } catch { + // The logout request failed, so the session is still valid server-side: + // leave the user where they are (the button re-enables for a retry) rather + // than pretending they're signed out. logout.isError drives the label. + setOpen(false) + } + } + + 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<{ to: string; label: string; icon: string }> +}) { + return ( + + ) +}