mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk (start ~670 to ~200 ms throttled). Guided follow-ups are personalised from the user's snapshot and an answer gets the coach's streamed reaction before the next question. Archived schedule eras can be corrected through a superseding revision that keeps the original as an audit trail, era writes serialise per medication, and duplicate dose times collapse in validation. A rolling medication whose next dose is tomorrow can no longer read as heavily overdue — the card status derives strictly from the served next-due — and the adherence bars keep a fixed geometry in every state. The server-wide assistant key gains its admin card, the one duplicated coach preference collapses onto a single owner, twenty-seven admin and settings card headers move onto the shared pattern with sixty containers normalised, and the remaining hydration warnings are gone.
339 lines
11 KiB
TypeScript
339 lines
11 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,
|
|
Database,
|
|
FileText,
|
|
Info,
|
|
KeyRound,
|
|
Inbox,
|
|
MessageSquare,
|
|
Plug,
|
|
Radio,
|
|
ScrollText,
|
|
Server,
|
|
Settings,
|
|
ShieldAlert,
|
|
SlidersHorizontal,
|
|
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,
|
|
},
|
|
{
|
|
slug: "ai-quality",
|
|
titleKey: "admin.section.ai-quality.title",
|
|
icon: Sparkles,
|
|
},
|
|
{
|
|
slug: "assistant",
|
|
titleKey: "admin.section.assistant.title",
|
|
icon: SlidersHorizontal,
|
|
},
|
|
{
|
|
slug: "coach-feedback",
|
|
titleKey: "admin.section.coach-feedback.title",
|
|
icon: MessageSquare,
|
|
},
|
|
{
|
|
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.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">
|
|
{/* 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>
|
|
|
|
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
|
|
{/* Desktop sticky sidebar */}
|
|
<aside
|
|
aria-label={t("admin.shell.sectionsNav")}
|
|
className="hidden 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 — page renders its own h1 + subtitle. 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">{children}</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|