"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 (
{/* 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 (
);
}