mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The settings and admin shells reserved the page-heading height in the left nav with a hidden replica block. The replica wrapped differently in the 220px nav column than the real heading in the wide content column, so the first nav item sat too low and drifted across locales. Hoist the heading into the shell and place it in its own grid row spanning only the content column: row 1 holds the heading, row 2 holds the nav and the cards. The nav's first item and the first card now start the same grid row, so they align by construction regardless of how the title or subtitle wraps. Drop the spacer component.
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* `<AdminShell>` — sidebar + main column shell for the v1.5 admin split.
|
|
*
|
|
* Mirrors `<SettingsShell>` (`src/components/settings/settings-shell.tsx`):
|
|
* each section lives at its own route under `/admin/[section]/page.tsx` and
|
|
* mounts inside this shell. The shell only owns navigation; the page itself
|
|
* renders the section heading and content.
|
|
*
|
|
* Mobile-first per `docs/ui-guidelines.md`:
|
|
* - <md: section selector renders as a horizontal scroll strip at the top.
|
|
* - >=md: sticky 220px sidebar + content column with `max-w-screen-xl`.
|
|
*/
|
|
|
|
import * as React from "react";
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import {
|
|
Bell,
|
|
Blocks,
|
|
Database,
|
|
FileText,
|
|
Info,
|
|
KeyRound,
|
|
Inbox,
|
|
Plug,
|
|
Radio,
|
|
ScrollText,
|
|
Server,
|
|
Settings,
|
|
ShieldAlert,
|
|
Sparkles,
|
|
Ticket,
|
|
Users,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { scrollBehaviorForUser } from "@/lib/motion";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
|
|
|
|
interface AdminSection {
|
|
slug: AdminSectionSlug;
|
|
/** i18n key under `admin.section.<slug>.title`. */
|
|
titleKey: string;
|
|
icon: LucideIcon;
|
|
}
|
|
|
|
/**
|
|
* Source of truth for the admin section list. Order matches the in-app
|
|
* navigation order so the sidebar, mobile strip, and `generateStaticParams()`
|
|
* all line up. Don't reorder without updating tests.
|
|
*/
|
|
export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
|
{
|
|
slug: "system-status",
|
|
titleKey: "admin.section.system-status.title",
|
|
icon: Server,
|
|
},
|
|
{
|
|
slug: "general",
|
|
titleKey: "admin.section.general.title",
|
|
icon: Settings,
|
|
},
|
|
{
|
|
slug: "services",
|
|
titleKey: "admin.section.services.title",
|
|
icon: Radio,
|
|
},
|
|
{
|
|
slug: "integrations",
|
|
titleKey: "admin.section.integrations.title",
|
|
icon: Plug,
|
|
},
|
|
// v1.18.1 — single "Coach" entry replacing the former ai-quality,
|
|
// assistant, and coach-feedback trio. The section body stacks all
|
|
// three surfaces so the coach-quality picture lives in one place.
|
|
{
|
|
slug: "coach",
|
|
titleKey: "admin.section.coach.title",
|
|
icon: Sparkles,
|
|
},
|
|
// v1.18.6 (W9) — server-wide module availability split out of Coach into
|
|
// its own entry, sitting next to it.
|
|
{
|
|
slug: "module-availability",
|
|
titleKey: "admin.section.module-availability.title",
|
|
icon: Blocks,
|
|
},
|
|
{
|
|
slug: "feedback",
|
|
titleKey: "admin.section.feedback.title",
|
|
icon: Inbox,
|
|
},
|
|
{
|
|
slug: "reminders",
|
|
titleKey: "admin.section.reminders.title",
|
|
icon: Bell,
|
|
},
|
|
{
|
|
slug: "users",
|
|
titleKey: "admin.section.users.title",
|
|
icon: Users,
|
|
},
|
|
// v1.16.0 — invites as their own section, directly after Users
|
|
// (admission lives next to the accounts it creates).
|
|
{
|
|
slug: "invites",
|
|
titleKey: "admin.section.invites.title",
|
|
icon: Ticket,
|
|
},
|
|
{
|
|
slug: "api-tokens",
|
|
titleKey: "admin.section.api-tokens.title",
|
|
icon: KeyRound,
|
|
},
|
|
{
|
|
slug: "login-overview",
|
|
titleKey: "admin.section.login-overview.title",
|
|
icon: ScrollText,
|
|
},
|
|
{
|
|
slug: "app-logs",
|
|
titleKey: "admin.section.app-logs.title",
|
|
icon: FileText,
|
|
},
|
|
{
|
|
slug: "backups",
|
|
titleKey: "admin.section.backups.title",
|
|
icon: Database,
|
|
},
|
|
{
|
|
slug: "danger-zone",
|
|
titleKey: "admin.section.danger-zone.title",
|
|
icon: ShieldAlert,
|
|
},
|
|
// v1.4.36 W4e — About / version / update check folded into Admin.
|
|
{
|
|
slug: "about",
|
|
titleKey: "admin.section.about.title",
|
|
icon: Info,
|
|
},
|
|
] as const;
|
|
|
|
export interface AdminShellProps {
|
|
/**
|
|
* Optional override for the active section. When omitted the shell reads
|
|
* the active slug from `usePathname()` (the production behaviour). Tests
|
|
* pass an explicit value so they don't need to mock the router.
|
|
*/
|
|
active?: AdminSectionSlug;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function deriveActiveSlug(
|
|
pathname: string | null,
|
|
override?: AdminSectionSlug,
|
|
): AdminSectionSlug | null {
|
|
if (override) return override;
|
|
if (!pathname) return null;
|
|
// Match `/admin/<slug>` and ignore any trailing segments. The
|
|
// `/admin` overview page falls through to `null` so no entry is
|
|
// marked active.
|
|
const match = pathname.match(/^\/admin\/([^/]+)/);
|
|
const candidate = match?.[1] ?? "";
|
|
return isAdminSectionSlug(candidate) ? candidate : null;
|
|
}
|
|
|
|
export function AdminShell({ active, children }: AdminShellProps) {
|
|
const pathname = usePathname();
|
|
const { t } = useTranslations();
|
|
const { user } = useAuth();
|
|
const activeSlug = deriveActiveSlug(pathname, active);
|
|
|
|
// v1.4.34 IW-G — mirror the settings-shell auto-scroll pattern so
|
|
// the active chip stays in view when the user lands on a deeper
|
|
// admin route. Without it the 16-chip strip stays scrolled to the
|
|
// left and the active chip lives off-screen, reading as a broken nav.
|
|
const mobileStripRef = React.useRef<HTMLElement | null>(null);
|
|
React.useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
const strip = mobileStripRef.current;
|
|
if (!strip) return;
|
|
const activeChip = strip.querySelector<HTMLElement>(
|
|
'[aria-current="page"]',
|
|
);
|
|
if (!activeChip) return;
|
|
activeChip.scrollIntoView({
|
|
block: "nearest",
|
|
// v1.4.36 W4b — `inline: "start"` pins the active chip to the
|
|
// left edge of the scroller. `inline: "center"` over-scrolled
|
|
// and the first one or two chips were unreachable on narrow
|
|
// viewports.
|
|
inline: "start",
|
|
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
|
|
behavior: scrollBehaviorForUser(),
|
|
});
|
|
}, [activeSlug]);
|
|
|
|
// The section bodies are already role-gated, but the shell frame
|
|
// itself (the full section nav in two layouts) used to paint for a
|
|
// non-admin during the frames between auth resolving and AuthShell's
|
|
// redirect effect replacing the route. Render nothing — frame AND
|
|
// children — until the role is confirmed ADMIN.
|
|
if (!user || user.role !== "ADMIN") return null;
|
|
|
|
// v1.18.6.1 — the heading lives in the shell so it can occupy its own grid
|
|
// row spanning only the content column; the nav's first item then lines up
|
|
// with the top of the first card by construction. `/admin` overview
|
|
// (activeSlug === null) shows the console title; each section shows its own.
|
|
const headingTitle =
|
|
activeSlug === null ? t("admin.title") : t(`admin.section.${activeSlug}.title`);
|
|
const headingSubtitle =
|
|
activeSlug === null
|
|
? t("admin.subtitle")
|
|
: t(`admin.section.${activeSlug}.subtitle`);
|
|
|
|
const headingBlock = (
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">{headingTitle}</h1>
|
|
<p className="text-muted-foreground text-sm">{headingSubtitle}</p>
|
|
</div>
|
|
);
|
|
|
|
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
|
|
// already, so this inner shell only carries the wider max-width.
|
|
// Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was
|
|
// producing visibly more top/bottom whitespace on Settings/Admin
|
|
// pages than on Dashboard/Insights/Measurements.
|
|
return (
|
|
<div className="mx-auto w-full max-w-screen-xl">
|
|
{/* v1.18.6.1 — heading above the chip strip on mobile; on desktop it
|
|
renders inside the grid (row 1 / content column). */}
|
|
<div className="mb-4 md:hidden">{headingBlock}</div>
|
|
|
|
{/* Mobile section strip — horizontal scroll, hidden on md+.
|
|
`no-scrollbar` (defined in `globals.css`) suppresses the
|
|
painted scrollbar at the top of every admin page; the
|
|
horizontal swipe + keyboard arrow scrolling still work, the
|
|
bar just doesn't draw. Without this the 13-section strip
|
|
would render an always-on scrollbar that the maintainer kept
|
|
mis-attributing to the api-tokens table below it.
|
|
|
|
v1.4.34 IW-G — `snap-x snap-mandatory` plus per-chip
|
|
`snap-start` lets a swipe-flick land on the next chip's
|
|
leading edge instead of the in-between dead zone, matching
|
|
the settings-shell polish. */}
|
|
<nav
|
|
ref={mobileStripRef}
|
|
aria-label={t("admin.shell.sectionsNav")}
|
|
className="no-scrollbar -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
|
|
>
|
|
<ul className="flex min-w-max gap-2">
|
|
<li className="snap-start">
|
|
<Link
|
|
href="/admin"
|
|
aria-current={activeSlug === null ? "page" : undefined}
|
|
className={cn(
|
|
// v1.4.25 W8 — chip strip is the primary mobile-admin
|
|
// navigation surface. Pad to WCAG 2.5.5 44 px so the
|
|
// chips can be tapped without zoom on a Pixel-5.
|
|
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
|
activeSlug === null
|
|
? "border-primary/40 bg-primary/10 text-primary"
|
|
: "border-border text-foreground hover:bg-accent",
|
|
)}
|
|
>
|
|
<ScrollText className="h-4 w-4" aria-hidden="true" />
|
|
{t("admin.shell.overview")}
|
|
</Link>
|
|
</li>
|
|
{ADMIN_SECTIONS.map((section) => {
|
|
const isActive = section.slug === activeSlug;
|
|
const Icon = section.icon;
|
|
return (
|
|
<li key={section.slug} className="snap-start">
|
|
<Link
|
|
href={`/admin/${section.slug}`}
|
|
aria-current={isActive ? "page" : undefined}
|
|
className={cn(
|
|
// v1.4.25 W8 — chip strip is the primary mobile-admin
|
|
// navigation surface. Pad to WCAG 2.5.5 44 px so the
|
|
// chips can be tapped without zoom on a Pixel-5.
|
|
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
|
isActive
|
|
? "border-primary/40 bg-primary/10 text-primary"
|
|
: "border-border text-foreground hover:bg-accent",
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
{t(section.titleKey)}
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
|
|
{/* v1.18.6.1 — two-row grid: heading in row 1 / col 2, nav in row 2 /
|
|
col 1, cards in row 2 / col 2. The nav's first item starts the same
|
|
grid row as the first card, so they align by construction across
|
|
locales and however the heading wraps — no fixed-height spacer. */}
|
|
<div className="grid gap-6 md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]">
|
|
{/* Heading — desktop only (mobile renders it above the strip). */}
|
|
<div className="hidden md:col-start-2 md:row-start-1 md:block">
|
|
{headingBlock}
|
|
</div>
|
|
|
|
{/* Desktop sticky sidebar — starts at the cards row (row 2). */}
|
|
<aside
|
|
aria-label={t("admin.shell.sectionsNav")}
|
|
className="hidden md:col-start-1 md:row-start-2 md:block"
|
|
>
|
|
<div className="sticky top-20">
|
|
<ul className="space-y-1">
|
|
<li>
|
|
<Link
|
|
href="/admin"
|
|
aria-current={activeSlug === null ? "page" : undefined}
|
|
className={cn(
|
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
|
activeSlug === null
|
|
? "bg-primary/10 text-primary"
|
|
: "text-foreground hover:bg-accent",
|
|
)}
|
|
>
|
|
<ScrollText className="h-4 w-4" aria-hidden="true" />
|
|
{t("admin.shell.overview")}
|
|
</Link>
|
|
</li>
|
|
{ADMIN_SECTIONS.map((section) => {
|
|
const isActive = section.slug === activeSlug;
|
|
const Icon = section.icon;
|
|
return (
|
|
<li key={section.slug}>
|
|
<Link
|
|
href={`/admin/${section.slug}`}
|
|
aria-current={isActive ? "page" : undefined}
|
|
className={cn(
|
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
|
isActive
|
|
? "bg-primary/10 text-primary"
|
|
: "text-foreground hover:bg-accent",
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
{t(section.titleKey)}
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main column (cards) — row 2 / col 2. Same
|
|
`min-h-[calc(100dvh-12rem)]` reserve as `<SettingsShell>`
|
|
so navigating between admin sub-pages (e.g. system-status
|
|
→ login-overview) does not jump the page height while the
|
|
new section is still fetching. */}
|
|
<main className="min-h-[calc(100dvh-12rem)] min-w-0 md:col-start-2 md:row-start-2">
|
|
{children}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|