"use client"; import { useMemo, useState } from "react"; import { Bell, ChevronsLeft, ChevronsRight, LogOut, Monitor, Moon, MoreVertical, Settings, Shield, Sun, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu"; import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; import { isNavDestinationActive, visibleNavDestinations, visibleUtilityDestinations, } from "@/components/layout/nav-model"; import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics"; import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { useAuth, useLogout } from "@/hooks/use-auth"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { useMounted } from "@/hooks/use-mounted"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTheme } from "@/components/providers"; import { useTranslations } from "@/lib/i18n/context"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; const STORAGE_KEY = "healthlog-sidebar-collapsed"; function getInitials(name: string): string { return name .split(/[\s._-]+/) .slice(0, 2) .map((s) => s[0]?.toUpperCase() ?? "") .join(""); } /** * The sidebar footer's name-and-avatar block. * * A link into the account settings normally, and a plain block while this * browser is acting on somebody else's record — the settings routes refuse * under a switch, so the link would lead only to an explanation of why it did * not work. It keeps naming the person at the keyboard either way, which is * what a switched session needs it to do. */ function FooterIdentity({ sharedRecord, label, children, }: { sharedRecord: boolean; label: string; children: React.ReactNode; }) { const className = "flex min-w-0 flex-1 items-center gap-3 rounded-md transition-colors"; if (sharedRecord) { return
{children}
; } return ( {children} ); } function SidebarUserSection({ collapsed }: { collapsed: boolean }) { const { user } = useAuth(); const logout = useLogout(); const { theme, setTheme } = useTheme(); const { t } = useTranslations(); const avatarUrl = user?.avatarUrl ?? null; // v1.36.0 — while this browser is acting on somebody else's record the // account surfaces are unreachable, so the avatar stops being a door into // them. It still names the person at the keyboard, which is exactly what a // switched session needs it to do. const sharedRecord = user?.accountAccess?.active != null; if (!user) return null; const themeIcon = theme === "system" ? ( ) : theme === "dark" ? ( ) : ( ); const identityBlock = ( <> {avatarUrl && } {getInitials(user.username)}

{user.username}

{user.email && (

{user.email}

)}
); const dropdownContent = ( {/* R20 — the user header is the direct route into the account settings. Clicking the avatar / name jumps straight to `/settings/account` rather than making the user hunt for a separate "Settings" entry. */} {sharedRecord ? (
{identityBlock}
) : ( {identityBlock} )} {!sharedRecord && ( {t("nav.notifications")} )} {/* The about section lives at the end of the settings shell nav; the avatar menu stays focused on account-level actions. */} {themeIcon} {t("nav.theme")} setTheme("system")}> {t("nav.themeSystem")} {theme === "system" && ( ✓ )} setTheme("dark")}> {t("nav.themeDark")} {theme === "dark" && ( ✓ )} setTheme("light")}> {t("nav.themeLight")} {theme === "light" && ( ✓ )} {/* v1.36.0 — the records this account may open. Renders nothing when nobody has shared one, so the menu is unchanged for every account that does not use sharing. */} logout.mutate()} className="text-destructive focus:text-destructive focus:bg-destructive/10 cursor-pointer" > {t("nav.logout")}
); if (collapsed) { return (
{avatarUrl && ( )} {getInitials(user.username)} {dropdownContent}
); } return (
{/* R20 — the avatar + name in the expanded footer routes straight into the account settings; the kebab to the right still opens the rest of the user menu. */} {avatarUrl && } {getInitials(user.username)}

{user.username}

{user.email && (

{user.email}

)}
{dropdownContent}
); } export function SidebarNav() { const pathname = usePathname(); const { t } = useTranslations(); const { user } = useAuth(); const queryClient = useQueryClient(); // v1.16.7 — hover / focus intent on the medications link starts the // list request before the navigation commits, so the due-time cells // hydrate from a warm cache instead of serialising behind the chunk. const medsIntent = medicationsPrefetchIntentProps(queryClient); const isAdmin = user?.role === "ADMIN"; // Match `/admin` exactly or any `/admin/...` sub-route for active-link // styling. Plain `startsWith("/admin")` would also flip for a // hypothetical future `/administrative` page, which is not the same // semantic surface. The Admin entry mirrors Settings: a single link // with no sub-item expansion in the global sidebar — `` // renders its own per-section nav inside the page itself. const onAdminPage = pathname === "/admin" || pathname.startsWith("/admin/"); // v1.17.1 (F-1) — the sidebar renders the one shared destination model // (`nav-model.ts`), the same ordered list the mobile bottom-nav derives // its "More" hub from. v1.18.0 — entries are filtered by the account's // resolved module map (mood, cycle, labs, coach, achievements …); cycle + // coach are delegated server-side and already reflected in that map. // The module map rides the client-only `/api/auth/me` query, so it is not // settled on SSR or the first client paint. Gating the filter behind // `useMounted()` keeps SSR and first paint identical (core destinations // only) and stops a disabled module's entry from flickering in for one // frame before the query resolves; once mounted the real map applies. const mounted = useMounted(); // v1.36.0 — while this browser is acting on somebody else's record, the nav // shows only what sharing covers. The server refuses the rest regardless; // dropping the entries spares a delegate a click that ends in a 403. const { inSharedRecord: sharedRecord, sections } = useRecordCapabilities(); const visibleNavItems = useMemo( () => visibleNavDestinations(user?.modules, mounted, sharedRecord, sections), [user?.modules, mounted, sharedRecord, sections], ); // Collapsed = the user's stored choice, or — with no stored choice — a // viewport default: tablet widths (md–lg, e.g. an iPad held upright) // fall back to the icon rail so the content column keeps its room; a // 256 px sidebar squeezes a 768 px viewport down to a 512 px column. // Everything is gated on `mounted` (same pattern as the module filter // above): SSR and the hydration render both paint the expanded shell, // the stored pref / viewport default applies on the first client render // after hydration settles. Branching earlier — localStorage or // matchMedia inside the initial render — is a React #418 hydration // mismatch, because the sidebar is only CSS-hidden below `md` and still // hydrates its DOM there. const tabletOrBelow = useIsMobile("lg"); const [collapsedPref, setCollapsedPref] = useState(() => { if (typeof window === "undefined") return null; try { const stored = localStorage.getItem(STORAGE_KEY); return stored === null ? null : stored === "true"; } catch { return null; } }); const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false; function toggleCollapsed() { const next = !collapsed; setCollapsedPref(next); try { localStorage.setItem(STORAGE_KEY, String(next)); } catch { // Ignore storage errors } } // v1.17.1 (F-1 residue) — the sidebar footer utility links derive from // the SAME shared list the mobile More-hub tail consumes, so the two // surfaces can no longer drift into two hand-curated utility lists. // Notifications is surfaced in the avatar menu (not the footer), so the // footer takes every utility entry except `/notifications`; Admin is the // role-gated, sidebar-only surface and is inserted separately below. const footerUtilityItems = useMemo( () => visibleUtilityDestinations({ sharedRecord }).filter( (d) => d.href !== "/notifications", ), [sharedRecord], ); function isUtilityActive(href: string) { // Settings matches the whole `/settings/*` shell; the rest match exact. return href === "/settings/account" ? pathname.startsWith("/settings") : pathname === href; } function renderUtilityLink(item: { href: string; tKey: string; icon: typeof Settings; }) { const Icon = item.icon; const isActive = isUtilityActive(item.href); const tourId = item.href === "/settings/account" ? "nav-settings" : undefined; if (collapsed) { return ( {t(item.tKey)} ); } return ( {t(item.tKey)} ); } function renderAdminLink() { if (!isAdmin) return null; if (collapsed) { return ( {t("nav.admin")} ); } return ( {t("nav.admin")} ); } function renderCollapseToggle(className?: string) { return ( {t("nav.collapseSidebar")} ); } return ( ); }