"use client"; import { useMemo, useRef, useState } from "react"; import { Bell, Keyboard, LogOut, Monitor, Moon, MoreVertical, PanelLeftClose, PanelLeftOpen, 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 { openShortcutsHelp } from "@/components/layout/global-shortcuts"; import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; import { isNavDestinationActive, isSettingsUtilityDestination, visibleNavDestinations, visibleUtilityDestinations, } from "@/components/layout/nav-model"; import { RAIL_ACCOUNT_BAND_CLASS, RAIL_BAND_INSET, RAIL_WIDTH_COLLAPSED, RAIL_WIDTH_EXPANDED, SHELL_HEADER_BAND, } from "@/components/layout/shell-metrics"; import { RailNavList, useNavOverflows, } from "@/components/layout/sidebar-nav-scroll"; import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { useAuth, useLogout } from "@/hooks/use-auth"; import { accountName, nameInitials } from "@/lib/account-name"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { useNavModules } from "@/hooks/use-nav-modules"; 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, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; const SIDEBAR_COLLAPSED_STORAGE_KEY = "healthlog-sidebar-collapsed"; /** * The expanded sidebar's content width: `w-64` minus the rail's 1 px * `border-r`, so the column fills the expanded rail exactly. Applied as a * floor while expanded, it keeps the content at its final layout during the * width transition (see the `sidebar-column` comment in the render). */ export const SIDEBAR_EXPANDED_CONTENT_WIDTH = "min-w-[calc(16rem-1px)]"; /** * The stored collapse choice: `true` / `false`, or `null` when this browser * has none (or storage is unavailable), which lets the viewport default decide. */ export function readSidebarCollapsedPref(): boolean | null { if (typeof window === "undefined") return null; try { const stored = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY); return stored === null ? null : stored === "true"; } catch { return null; } } export function writeSidebarCollapsedPref(collapsed: boolean): void { try { localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed)); } catch { // Private mode or blocked storage: the choice lasts for this page only. } } /** * 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 block names the person at * the keyboard, and `/settings/account` inside a record is either refused (an * adult share) or the RECORD's profile card (a managed profile) — a door on * one's own name into somebody else's settings would mislabel both. A record * that has Settings pages reaches them through the footer Settings entry, * which is labelled as Settings and lands where the shell opens. */ 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 ( {/* The purpose leads the accessible name; the visible name and email follow it, so the link is named by what it shows (WCAG 2.5.3). */} {label} {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 name = accountName(user); const themeIcon = theme === "system" ? ( ) : theme === "dark" ? ( ) : ( ); const identityBlock = ( <> {avatarUrl && } {nameInitials(name)}

{name}

{user.email && (

{user.email}

)}
); const dropdownContent = ( {/* 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")} )} {/* v1.42 — the keyboard shortcuts, findable without knowing that `?` lists them. Desktop only, like this menu: the phone's account menu has no keyboard to describe. */} openShortcutsHelp()} className="cursor-pointer" > {t("shortcuts.menuEntry")} {/* 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 (
{t("nav.userMenu")} {dropdownContent}
); } return (
{/* 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. */}

{name}

{user.email && (

{user.email}

)}
{dropdownContent}
); } /** * Height-based density for the rail. A 13-inch laptop at 100 % zoom leaves * the browser roughly 700–860 px, and the full destination list plus the * footer rows did not fit: the list scrolled, and on a classic-scrollbar * platform (Windows) the rail grew a native scrollbar with arrow buttons. * On short windows the rows close their gaps first, then step their height * down; tall windows keep the roomy default. Desktop-only by construction — * the rail is hidden below `md`, so the phone layout never sees these steps. * * Steps (rows are full-width, so the click target is the whole row): * - default: 40 px rows, 4 px apart * - max-height 940 px: gaps close (40 px pitch), the list padding tightens * (the account band keeps the logo band's height, `RAIL_ACCOUNT_BAND`) * - max-height 820 px: 36 px rows * - max-height 740 px: 32 px rows — the one step under 40 px, taken only * where the alternative is rows hidden behind a scroll edge */ // Disjoint height ranges, not nested max-heights: two arbitrary media // variants on one property cascade in stylesheet order, not by how narrow // the query is, so a nested pair let the 36 px step win on a 720 px window. // The height steps apply to a mouse only. Under a finger (a touch tablet) // the rows keep the 44 px touch floor and the list scrolls behind its fades // instead. A phone held sideways never sees the rail: it keeps the phone // shell (`shell-mobile`). const RAIL_ROW_HEIGHT = "min-h-10 pointer-coarse:min-h-11 [@media(hover:hover)_and_(pointer:fine)_and_(740px void; }) { const { t } = useTranslations(); const Icon = collapsed ? PanelLeftOpen : PanelLeftClose; const actionLabel = t( collapsed ? "nav.expandSidebarLabel" : "nav.collapseSidebarLabel", ); const button = ( ); if (!collapsed) return button; return ( {button} {actionLabel} ); } 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 — 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, recordKind, level, } = useRecordCapabilities(); // The sections the grant may manage, bound from the server-resolved entry. // The Settings entry needs them: a record-content page is offered only when // the grant manages the section its forms write. const manageableDomains = user?.accountAccess?.active?.manageableDomains; // The Coach entry follows the `coach` AI capability, like every other // Coach entry point; the rest of the map is `modules` unchanged. const navModules = useNavModules(); const visibleNavItems = useMemo( () => visibleNavDestinations(navModules, mounted, sharedRecord, sections), [navModules, 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( readSidebarCollapsedPref, ); const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false; const navRef = useRef(null); const navEdges = useNavOverflows(navRef); const navOverflows = navEdges.overflows; function toggleCollapsed() { const next = !collapsed; setCollapsedPref(next); writeSidebarCollapsedPref(next); } // v1.17.1 (F-1 residue) — the sidebar footer utility links derive from // the SAME shared list the mobile user menu 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. // // Under a switch the list answers for the record on screen: a Settings // entry exactly when the Settings shell lists a section for it, pointing at // the first one (see `visibleUtilityDestinations`). const footerUtilityItems = useMemo( () => visibleUtilityDestinations({ record: sharedRecord ? { recordKind, level, manageableDomains: manageableDomains ?? [] } : null, }).filter((d) => d.href !== "/notifications"), [sharedRecord, recordKind, level, manageableDomains], ); function isUtilityActive(href: string) { // Settings matches the whole `/settings/*` shell; the rest match exact. return isSettingsUtilityDestination({ href }) ? 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 isSettings = isSettingsUtilityDestination(item); const tourId = isSettingsUtilityDestination(item) ? "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")} ); } return ( ); }