mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(admin): scaffold per-section dynamic routes (admin-shell + [section]/page.tsx)
Mirrors the v1.4 settings split for the v1.5 admin refactor. Adds the shell, the slug constants, the dynamic-route page, and the client-only renderer that picks the right section component for each slug. The existing admin sections continue to live in src/components/admin/*.tsx; this scaffolding wires them into per-route bundles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
af36da3bd1
commit
d8b71b3848
4 files changed
+509
No files matched your search
@@ -0,0 +1,50 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { AdminShell } from "@/components/admin/admin-shell";
|
||||
import { AdminSectionRenderer } from "./renderer";
|
||||
import {
|
||||
ADMIN_SECTION_SLUGS,
|
||||
isAdminSectionSlug,
|
||||
} from "@/components/admin/section-slugs";
|
||||
|
||||
/**
|
||||
* Dynamic admin section route — mirrors the v1.4 settings split. Each of
|
||||
* the `ADMIN_SECTION_SLUGS` is pre-rendered at build via
|
||||
* `generateStaticParams()` so the URLs are statically known to Next.js,
|
||||
* while `dynamicParams = false` tells the router to 404 (rather than
|
||||
* attempting on-demand rendering) for any slug not in the list.
|
||||
*
|
||||
* The actual section components are mounted by the client-only
|
||||
* `<AdminSectionRenderer>` because most of them use TanStack Query and
|
||||
* the auth hook (server-rendered they'd just paint a loader). The shell,
|
||||
* however, is server-rendered so the sidebar paints with no JS hydration
|
||||
* lag.
|
||||
*/
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return ADMIN_SECTION_SLUGS.map((section) => ({ section }));
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
// Next.js 16 made route `params` an async Promise. We `await` it before use.
|
||||
params: Promise<{ section: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminSectionPage({ params }: PageProps) {
|
||||
const { section } = await params;
|
||||
|
||||
// Defence-in-depth — `dynamicParams = false` already 404s unknown slugs at
|
||||
// routing time, but we re-check here so a hand-rolled override of the
|
||||
// route config can never silently fall through to a typo'd slug.
|
||||
if (!isAdminSectionSlug(section)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell active={section}>
|
||||
<AdminSectionRenderer slug={section} />
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Client-only renderer that picks the right admin section component for
|
||||
* a given slug. Lives next to the route page so the shell can stay a
|
||||
* server component while the auth-gated body remains a client component.
|
||||
*
|
||||
* This is the equivalent of `SECTION_COMPONENTS` in
|
||||
* `src/app/settings/[section]/page.tsx`, lifted to a separate file so
|
||||
* the route page can be a server component (Settings can keep
|
||||
* `"use client"`-leaning components in its page because none of them
|
||||
* read `useAuth()` to gate rendering).
|
||||
*/
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
|
||||
import { BackupsSection } from "@/components/admin/backups-section";
|
||||
import { DangerZoneSection } from "@/components/admin/danger-zone-section";
|
||||
import { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section";
|
||||
import { GeneralSettingsSection } from "@/components/admin/general-settings-section";
|
||||
import { IntegrationsGroupSection } from "@/components/admin/integrations-group-section";
|
||||
import { LoginOverviewSection } from "@/components/admin/login-overview-section";
|
||||
import { RemindersSection } from "@/components/admin/reminders-section";
|
||||
import { ServicesSection } from "@/components/admin/services-section";
|
||||
import { SystemStatusSection } from "@/components/admin/system-status-section";
|
||||
import { UserManagementSection } from "@/components/admin/user-management-section";
|
||||
import type { AdminSectionSlug } from "@/components/admin/section-slugs";
|
||||
|
||||
interface RendererProps {
|
||||
slug: AdminSectionSlug;
|
||||
}
|
||||
|
||||
export function AdminSectionRenderer({
|
||||
slug,
|
||||
}: RendererProps): JSX.Element | null {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
// The admin shell layout already gates non-admins via `<AuthShell>`,
|
||||
// but the auth hook is async — return nothing on the first render so
|
||||
// we don't flash sub-section UI to a non-admin who's about to get
|
||||
// redirected to `/`.
|
||||
if (!user || user.role !== "ADMIN") return null;
|
||||
|
||||
switch (slug) {
|
||||
case "system-status":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.system-status.title")}
|
||||
subtitle={t("admin.section.system-status.subtitle")}
|
||||
>
|
||||
<SystemStatusSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "general":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.general.title")}
|
||||
subtitle={t("admin.section.general.subtitle")}
|
||||
>
|
||||
<GeneralSettingsSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "services":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.services.title")}
|
||||
subtitle={t("admin.section.services.subtitle")}
|
||||
>
|
||||
<ServicesSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "integrations":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.integrations.title")}
|
||||
subtitle={t("admin.section.integrations.subtitle")}
|
||||
>
|
||||
<IntegrationsGroupSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "feedback":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.feedback.title")}
|
||||
subtitle={t("admin.section.feedback.subtitle")}
|
||||
>
|
||||
<FeedbackInboxSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "reminders":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.reminders.title")}
|
||||
subtitle={t("admin.section.reminders.subtitle")}
|
||||
>
|
||||
<RemindersSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "users":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.users.title")}
|
||||
subtitle={t("admin.section.users.subtitle")}
|
||||
>
|
||||
<UserManagementSection currentUserId={user.id} />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "api-tokens":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.api-tokens.title")}
|
||||
subtitle={t("admin.section.api-tokens.subtitle")}
|
||||
>
|
||||
<ApiTokenOverviewSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "login-overview":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.login-overview.title")}
|
||||
subtitle={t("admin.section.login-overview.subtitle")}
|
||||
>
|
||||
<LoginOverviewSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "backups":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.backups.title")}
|
||||
subtitle={t("admin.section.backups.subtitle")}
|
||||
>
|
||||
<BackupsSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "danger-zone":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.danger-zone.title")}
|
||||
subtitle={t("admin.section.danger-zone.subtitle")}
|
||||
>
|
||||
<DangerZoneSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
default: {
|
||||
// exhaustiveness guard — TS will flag a missing case here
|
||||
const _exhaustive: never = slug;
|
||||
void _exhaustive;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SectionFrameProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SectionFrame({ title, subtitle, children }: SectionFrameProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className="text-muted-foreground text-sm">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"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 {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
Database,
|
||||
KeyRound,
|
||||
Inbox,
|
||||
Plug,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
ADMIN_SECTION_SLUGS,
|
||||
isAdminSectionSlug,
|
||||
type AdminSectionSlug,
|
||||
} from "./section-slugs";
|
||||
|
||||
// Re-export so callers don't have to chase two modules. New server-side
|
||||
// callers (e.g. `generateStaticParams()`) should still import from
|
||||
// `./section-slugs` directly — values exported through this client
|
||||
// boundary become unusable proxies on the server.
|
||||
export { ADMIN_SECTION_SLUGS, isAdminSectionSlug, type AdminSectionSlug };
|
||||
|
||||
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: Plug,
|
||||
},
|
||||
{
|
||||
slug: "integrations",
|
||||
titleKey: "admin.section.integrations.title",
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
slug: "api-tokens",
|
||||
titleKey: "admin.section.api-tokens.title",
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
slug: "login-overview",
|
||||
titleKey: "admin.section.login-overview.title",
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
slug: "backups",
|
||||
titleKey: "admin.section.backups.title",
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
slug: "danger-zone",
|
||||
titleKey: "admin.section.danger-zone.title",
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
] 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 activeSlug = deriveActiveSlug(pathname, active);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 py-6 md:px-6 md:py-8">
|
||||
{/* Mobile section strip — horizontal scroll, hidden on md+. */}
|
||||
<nav
|
||||
aria-label={t("admin.shell.sectionsNav")}
|
||||
className="-mx-4 mb-4 overflow-x-auto px-4 md:hidden"
|
||||
>
|
||||
<ul className="flex min-w-max gap-2">
|
||||
{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-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 */}
|
||||
<main className="min-w-0">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Server-safe constants for the v1.5 admin split. Mirrors the pattern in
|
||||
* `src/components/settings/section-slugs.ts` so server-rendered code paths
|
||||
* (`generateStaticParams()`, the page guard, route-existence tests) can
|
||||
* import the slug list without dragging the `<AdminShell>` client bundle
|
||||
* into the server graph.
|
||||
*
|
||||
* Order is the in-app navigation order — sidebar nav, mobile strip, and
|
||||
* `generateStaticParams()` all consume it directly.
|
||||
*/
|
||||
|
||||
export const ADMIN_SECTION_SLUGS = [
|
||||
"system-status",
|
||||
"general",
|
||||
"services",
|
||||
"integrations",
|
||||
"feedback",
|
||||
"reminders",
|
||||
"users",
|
||||
"api-tokens",
|
||||
"login-overview",
|
||||
"backups",
|
||||
"danger-zone",
|
||||
] as const;
|
||||
|
||||
export type AdminSectionSlug = (typeof ADMIN_SECTION_SLUGS)[number];
|
||||
|
||||
export function isAdminSectionSlug(value: string): value is AdminSectionSlug {
|
||||
return (ADMIN_SECTION_SLUGS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy hash-anchor → new slug mapping. Used by `<StatusCardGrid>` to
|
||||
* rewrite hrefs and by `proxy.ts` to handle any server-side legacy URL
|
||||
* forms (path-style anchors are redirectable; pure `#fragments` never
|
||||
* reach the server, so those are fixed at the call-sites instead).
|
||||
*/
|
||||
export const LEGACY_ANCHOR_TO_SLUG: Record<string, AdminSectionSlug> = {
|
||||
"section-system-status": "system-status",
|
||||
"section-admin-general": "general",
|
||||
"section-admin-services": "services",
|
||||
"section-admin-umami": "integrations",
|
||||
"section-admin-glitchtip": "integrations",
|
||||
"section-admin-webpush": "integrations",
|
||||
"section-admin-bugreport": "integrations",
|
||||
"section-admin-feedback": "feedback",
|
||||
"section-admin-reminders": "reminders",
|
||||
"section-user-management": "users",
|
||||
"section-api-tokens": "api-tokens",
|
||||
"section-login-overview": "login-overview",
|
||||
"section-danger-zone": "danger-zone",
|
||||
};
|
||||
Reference in new issue
Block a user