mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The top bar's bottom border and the sidebar logo band's bottom border draw one horizontal line across the top of the app, and they sat a pixel apart. Both declared h-16, so the number was never the problem. Every box in the app is border-box: the top bar carried `h-16 border-b` on one element, which puts the line inside the 4rem at 63 to 64 px, while the sidebar put `border-b` on the wrapper and `h-16` on the link inside it, which puts the line after the 4rem at 64 to 65 px. Same number, different line. The height and the border now travel together as SHELL_HEADER_BAND in shell-metrics.ts, next to the sub-shell scroll floor that exists for the same reason. The element carrying that class is the band; neither call site restates a height, and the sidebar link stretches with h-full. Border colour stays per-surface, because the two chrome surfaces are tinted apart by design and only the geometry has to match. Two guards. chrome-header-seam.test.tsx asserts the structural property, that each band renders every token of the shared constant and declares no height of its own; a second height class on either element replaces the shared one through twMerge, so both halves fire on the same edit. The e2e spec measures the painted result at 768, 900, 1024 and 1280 px in both the expanded sidebar and the collapsed rail, and checks each band actually paints a border at the same weight first, so two missing lines cannot pass as two aligned ones.
562 lines
20 KiB
TypeScript
562 lines
20 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 { 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 { 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("");
|
||
}
|
||
|
||
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">✓</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">✓</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">✓</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 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.
|
||
// 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();
|
||
const visibleNavItems = useMemo(
|
||
() => visibleNavDestinations(user?.modules, mounted),
|
||
[user?.modules, mounted],
|
||
);
|
||
// 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<boolean | null>(() => {
|
||
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().filter((d) => d.href !== "/notifications"),
|
||
[],
|
||
);
|
||
|
||
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 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 band. Height + bottom border come from `SHELL_HEADER_BAND`,
|
||
which the top bar reads too, so the two borders draw one
|
||
continuous line. The band owns the 4rem; the link inside
|
||
stretches with `h-full` and never restates a height. */}
|
||
<div
|
||
data-slot="sidebar-header"
|
||
className={cn(
|
||
"border-sidebar-border",
|
||
SHELL_HEADER_BAND,
|
||
collapsed ? "px-3" : "px-6",
|
||
)}
|
||
>
|
||
<Link
|
||
href="/"
|
||
className={cn(
|
||
"flex h-full 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>
|
||
);
|
||
}
|