diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 4fe42f1..e5dd25f 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react' -import { Link, Outlet, useMatchRoute, useNavigate } from '@tanstack/react-router' +import { 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. @@ -11,6 +12,8 @@ const sections = [ { 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. @@ -30,10 +33,13 @@ export function AppShell() { const user = me.data const matchRoute = useMatchRoute() - // The garden editor route ('/gardens/$gardenId') runs full-screen. Fuzzy:false - // so the '/gardens' list itself still shows the bottom bar. + // 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 showBottomNav = !!user && !inEditor + const inPublicGarden = !!matchRoute({ to: '/g/$token', fuzzy: false }) + const showBottomNav = !!user && !inEditor && !inPublicGarden const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin) @@ -79,11 +85,13 @@ export function AppShell() { - {/* Bottom bar clearance on mobile so content isn't hidden behind it. */}
@@ -101,16 +109,21 @@ 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: - // 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) + // 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. } } @@ -169,11 +182,7 @@ function AccountMenu({ displayName }: { displayName: string }) { } /** 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 }> -}) { +function BottomNav({ sections }: { sections: ReadonlyArray
}) { return (