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:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 16:59:08 +02:00
1 parent af36da3bd1
commit d8b71b3848
4 files changed
+509

No files matched your search

+50
View File
@@ -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>
);
}
+175
View File
@@ -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>
);
}
+232
View File
@@ -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>
);
}
+52
View File
@@ -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",
};