Reorganize admin into tabbed hubs with a condensed sidebar.
Local Build and Deploy / deploy (push) Successful in 57s

Replace the long flat nav with hub entries, shared AdminHubChrome tabs, and AdminPageShell. Existing URLs stay stable; Settings/Radio and other sections now share one chrome.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-15 21:29:36 +02:00
1 parent 367f69a439
commit 363c9b3d56
30 files changed
+854 -432

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
"use client";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { AdminSectionTabs } from "@/components/admin/admin-section-tabs";
import { findAdminHub } from "@/lib/admin-nav";
/**
* When the current path belongs to a hub, wraps children with a shared
* title + tab strip. Dashboard and unknown routes render children as-is.
*/
export function AdminHubChrome({ children }: { children: ReactNode }) {
const pathname = usePathname() ?? "";
const hub = findAdminHub(pathname);
if (!hub) return <>{children}</>;
return (
<AdminPageShell
icon={hub.icon}
title={hub.title}
subtitle={hub.subtitle}
tabs={<AdminSectionTabs tabs={hub.tabs} />}
className="admin-hub-panel"
>
{children}
</AdminPageShell>
);
}
+15 -7
View File
@@ -6,25 +6,33 @@ import type { ReactNode } from "react";
import { useAdminMobile } from "@/components/admin/admin-mobile-wrapper";
/**
* Sidebar link that highlights when its route is active. "/admin" matches
* exactly; every other entry also matches its sub-routes (e.g. /admin/radio is
* active on /admin/radio/history) so the current section stays lit.
* Sidebar link that highlights when its route is active.
* Optional matchPrefixes cover multi-page hubs (e.g. Settings → theme/vpn).
*/
export function AdminNavLink({
href,
icon,
matchPrefixes,
children,
}: {
href: string;
icon?: ReactNode;
matchPrefixes?: string[];
children: ReactNode;
}) {
const pathname = usePathname() ?? "";
const { close } = useAdminMobile();
const active =
href === "/admin"
? pathname === "/admin"
: pathname === href || pathname.startsWith(`${href}/`);
let active: boolean;
if (href === "/admin") {
active = pathname === "/admin";
} else if (matchPrefixes?.length) {
active = matchPrefixes.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
} else {
active = pathname === href || pathname.startsWith(`${href}/`);
}
return (
<Link
+52
View File
@@ -0,0 +1,52 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
interface AdminPageShellProps {
icon?: LucideIcon;
title: string;
subtitle?: string;
actions?: ReactNode;
tabs?: ReactNode;
children: ReactNode;
className?: string;
}
/** Shared admin page chrome: icon title, optional actions/tabs, then body. */
export function AdminPageShell({
icon: Icon,
title,
subtitle,
actions,
tabs,
children,
className = "",
}: AdminPageShellProps) {
return (
<div className={`space-y-5 ${className}`.trim()}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
{Icon ? (
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center shrink-0">
<Icon size={20} className="text-[var(--admin-accent)]" />
</div>
) : null}
<div className="min-w-0">
<h1 className="m-0 text-2xl font-extrabold tracking-tight text-[var(--admin-text)]">
{title}
</h1>
{subtitle ? (
<p className="m-0 mt-0.5 text-sm text-[var(--admin-text-muted)]">
{subtitle}
</p>
) : null}
</div>
</div>
{actions ? (
<div className="flex flex-wrap items-center gap-2">{actions}</div>
) : null}
</div>
{tabs}
<div className="min-w-0">{children}</div>
</div>
);
}
@@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
export interface AdminTab {
href: string;
label: string;
/** Extra prefixes that count as active for this tab (detail routes). */
match?: string[];
}
function matchLength(pathname: string, prefix: string): number {
if (pathname === prefix) return prefix.length + 1; // prefer exact
if (pathname.startsWith(`${prefix}/`)) return prefix.length;
return -1;
}
export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
const pathname = usePathname() ?? "";
let bestIndex = -1;
let bestScore = -1;
tabs.forEach((tab, index) => {
const prefixes = tab.match ?? [tab.href];
for (const prefix of prefixes) {
const score = matchLength(pathname, prefix);
if (score > bestScore) {
bestScore = score;
bestIndex = index;
}
}
});
return (
<nav
className="flex gap-1 overflow-x-auto border-b border-[var(--admin-border)] -mx-1 px-1"
aria-label="Section"
>
{tabs.map((tab, index) => {
const active = index === bestIndex;
return (
<Link
key={tab.href}
href={tab.href}
aria-current={active ? "page" : undefined}
className={cn(
"-mb-px shrink-0 rounded-t-lg border-b-2 px-3.5 py-2.5 text-sm font-semibold no-underline transition-colors",
active
? "border-[var(--admin-accent)] text-[var(--admin-text)] bg-[var(--admin-accent)]/8"
: "border-transparent text-[var(--admin-text-muted)] hover:text-[var(--admin-text)] hover:bg-[var(--admin-accent)]/5",
)}
>
{tab.label}
</Link>
);
})}
</nav>
);
}