Files
HealthLog/src/components/layout/sidebar-nav.tsx
T
Marc-André Bombeck 5682055fe2 feat(modules): hide nav entries and Insights pills for disabled modules
Generalise the per-destination cycle gate into a requiresModule marker
on each nav destination and read it from the account's resolved module
map (the modules field on GET /api/auth/me). Fold the bespoke cycle
boolean into requiresModule: "cycle" so there is one gating mechanism;
cycle and coach are delegated keys already reflected in the map, so
reading them here is not a re-derivation.

Tag mood, cycle, labs, coach and achievements with their module keys;
core destinations (measurements, medications, vorsorge, insights, the
dashboard) carry none and always render. The sidebar, the mobile More
hub, and the Insights tab strip all filter on the same map.

Insights pills: a metric pill belonging to a toggleable module (mood,
sleep, glucose, workouts) hides when its module is disabled, on top of
the existing data + saved-layout gates; the Recovery pill gates on the
recovery module. Overview and core-metric pills always show. Every gate
fails open — a missing key or an absent map keeps the entry, matching
the gate's default-on contract, so a stale /me payload never blanks the
nav.
2026-06-15 21:18:47 +02:00

537 lines
18 KiB
TypeScript

"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 { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
import {
isNavDestinationActive,
visibleNavDestinations,
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { useTheme } from "@/components/providers";
import { useAppSettings } from "@/components/app-settings-provider";
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("");
}
function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
const { user } = useAuth();
const logout = useLogout();
const { theme, setTheme } = useTheme();
const { t } = useTranslations();
const avatarUrl = user?.avatarUrl ?? null;
if (!user) return null;
const themeIcon =
theme === "system" ? (
<Monitor className="h-4 w-4" />
) : theme === "dark" ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
);
const dropdownContent = (
<DropdownMenuContent
side="right"
align="end"
className="w-60"
sideOffset={8}
>
{/* 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. */}
<DropdownMenuItem asChild>
<Link
href="/settings/account"
className="flex cursor-pointer items-center gap-3 px-2 py-2"
>
<Avatar className="h-9 w-9 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{user.username}</p>
{user.email && (
<p className="text-muted-foreground truncate text-xs">
{user.email}
</p>
)}
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/notifications" className="cursor-pointer">
<Bell className="mr-2 h-4 w-4" />
{t("nav.notifications")}
</Link>
</DropdownMenuItem>
{/* The about section lives at the end of the settings shell nav;
the avatar menu stays focused on account-level actions. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{themeIcon}
<span className="ml-2">{t("nav.theme")}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
{t("nav.themeSystem")}
{theme === "system" && (
<span className="text-primary ml-auto text-xs">&#10003;</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
{t("nav.themeDark")}
{theme === "dark" && (
<span className="text-primary ml-auto text-xs">&#10003;</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
{t("nav.themeLight")}
{theme === "light" && (
<span className="text-primary ml-auto text-xs">&#10003;</span>
)}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => logout.mutate()}
className="text-destructive focus:text-destructive focus:bg-destructive/10 cursor-pointer"
>
<LogOut className="mr-2 h-4 w-4" />
{t("nav.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
);
if (collapsed) {
return (
<div className="border-sidebar-border border-t p-3">
<div className="flex items-center justify-center">
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t("nav.userMenu")}
className="hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]"
>
<Avatar className="h-8 w-8">
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={user.username} />
)}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
{dropdownContent}
</DropdownMenu>
</div>
</div>
);
}
return (
<div className="border-sidebar-border border-t p-3">
<div className="flex items-center gap-3 px-2 py-1">
{/* 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. */}
<Link
href="/settings/account"
aria-label={t("nav.accountSettings")}
className="hover:bg-accent flex min-w-0 flex-1 items-center gap-3 rounded-md transition-colors"
>
<Avatar className="h-8 w-8 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.username}</p>
{user.email && (
<p className="text-muted-foreground truncate text-xs">
{user.email}
</p>
)}
</div>
</Link>
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t("nav.userMenu")}
className="text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]"
>
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
{dropdownContent}
</DropdownMenu>
</div>
</div>
);
}
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 { bugReportEnabled } = useAppSettings();
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 — `<AdminShell>`
// 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.
const visibleNavItems = useMemo(
() => visibleNavDestinations(user?.modules),
[user?.modules],
);
const [collapsed, setCollapsed] = useState(() => {
if (typeof window === "undefined") return false;
try {
return localStorage.getItem(STORAGE_KEY) === "true";
} catch {
return false;
}
});
function toggleCollapsed() {
setCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(STORAGE_KEY, String(next));
} catch {
// Ignore storage errors
}
return next;
});
}
// 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(bugReportEnabled).filter(
(d) => d.href !== "/notifications",
),
[bugReportEnabled],
);
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 (
<Tooltip key={item.href}>
<TooltipTrigger asChild>
<Link
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={tourId}
className={cn(
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" />
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{t(item.tKey)}
</TooltipContent>
</Tooltip>
);
}
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={tourId}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Icon className="h-4 w-4" />
{t(item.tKey)}
</Link>
);
}
function renderAdminLink() {
if (!isAdmin) return null;
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/admin"
aria-current={onAdminPage ? "page" : undefined}
className={cn(
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
onAdminPage
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Shield className="h-4 w-4" />
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{t("nav.admin")}
</TooltipContent>
</Tooltip>
);
}
return (
<Link
href="/admin"
aria-current={onAdminPage ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
onAdminPage
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<Shield className="h-4 w-4" />
{t("nav.admin")}
</Link>
);
}
function renderCollapseToggle(className?: string) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={toggleCollapsed}
className={cn(
"text-muted-foreground/80 hover:text-foreground hover:bg-accent z-20 rounded-md p-1 transition-colors",
className,
)}
aria-label={t("nav.collapseSidebar")}
aria-expanded={!collapsed}
>
{collapsed ? (
<ChevronsRight className="h-3.5 w-3.5" />
) : (
<ChevronsLeft className="h-3.5 w-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{t("nav.collapseSidebar")}
</TooltipContent>
</Tooltip>
);
}
return (
<TooltipProvider delayDuration={0}>
<aside
aria-label={t("nav.sidebar")}
className={cn(
"bg-sidebar border-sidebar-border relative hidden h-full flex-shrink-0 border-r transition-[width] duration-200 md:flex md:flex-col",
collapsed ? "w-16" : "w-64",
)}
>
{/* Logo */}
<div
className={cn(
"border-sidebar-border border-b",
collapsed ? "px-3" : "px-6",
)}
>
<Link
href="/"
className={cn(
"flex h-16 items-center",
collapsed ? "justify-center px-0" : "gap-2",
)}
>
<Logo className="text-primary shrink-0" size={24} />
{!collapsed && (
<span className="text-lg font-bold tracking-tight">
HealthLog
</span>
)}
</Link>
</div>
<nav
aria-label={t("nav.mainNavigation")}
className={cn("flex-1 overflow-y-auto", collapsed ? "p-1.5" : "p-3")}
>
{collapsed ? (
<div className="mb-1 flex justify-center">
{renderCollapseToggle()}
</div>
) : (
// v1.4.33 IW7 — drop the "Home" group label. The sidebar
// has exactly one nav group, and the first entry is
// already "Dashboard" pointing to `/`, so the previous
// "HOME / Dashboard" pairing read as if there were two
// separate destinations for the homepage. The collapse
// toggle stays anchored to the top-right of the strip so
// the visual rhythm of the sidebar header is unchanged.
<div className="relative mb-1 flex h-5 items-center justify-end">
{renderCollapseToggle()}
</div>
)}
<div className="space-y-1">
{visibleNavItems.map((item) => {
const isActive = isNavDestinationActive(
item.href,
pathname,
visibleNavItems,
);
const label = t(item.tKey);
if (collapsed) {
return (
<Tooltip key={item.href}>
<TooltipTrigger asChild>
<Link
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={item.tourId}
{...(item.href === "/medications" ? medsIntent : {})}
className={cn(
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<item.icon className="h-4 w-4" />
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
);
}
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={item.tourId}
{...(item.href === "/medications" ? medsIntent : {})}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<item.icon className="h-4 w-4" />
{label}
</Link>
);
})}
</div>
</nav>
{/* Bottom utility links — the shared utility tail (minus
Notifications, which lives in the avatar menu) with the
role-gated Admin entry inserted before Settings. */}
<div className={cn("space-y-1 pb-1", collapsed ? "px-1.5" : "px-3")}>
{footerUtilityItems
.filter((item) => item.href !== "/settings/account")
.map((item) => renderUtilityLink(item))}
{renderAdminLink()}
{footerUtilityItems
.filter((item) => item.href === "/settings/account")
.map((item) => renderUtilityLink(item))}
</div>
<SidebarUserSection collapsed={collapsed} />
</aside>
</TooltipProvider>
);
}