Files
Epicnabbo-Catalogus-Updated…/src/app/admin/layout.tsx
T
simoleo89andCursor 518ed14a83 Add collapsible admin sidebar nav groups with persistence.
Category headers toggle open/closed; state is stored in localStorage and the active route group stays expanded.

Co-authored-by: Cursor <[email protected]>
2026-07-18 22:15:33 +02:00

111 lines
3.6 KiB
TypeScript

import { LogOut } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
import { AdminSidebarNav } from "@/components/admin/admin-sidebar-nav";
import { AdminTopbar } from "@/components/admin/admin-topbar";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireStaff } from "@/lib/admin/guard";
import { setCsrfCookie } from "@/lib/foundation/security";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export default async function AdminLayout({
children,
}: {
children: ReactNode;
}) {
const staff = await requireStaff();
let csrfToken = "";
try {
csrfToken = await setCsrfCookie();
} catch {
csrfToken = "";
}
if (await siteSettings.getBool("force_staff_2fa", false)) {
const u = await prisma.user
.findUnique({
where: { id: staff.id },
select: { twoFactorConfirmedAt: true },
})
.catch(() => null);
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
}
return (
<>
{csrfToken ? (
<meta name="csrf-token" content={csrfToken} />
) : null}
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
<div
data-admin
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-screen"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<AdminTopbar staff={staff} />
<section className="admin-page flex-1">
<AdminHubChrome>{children}</AdminHubChrome>
</section>
</div>
</AdminMobileWrapper>
</>
);
}
async function Sidebar({
staff,
}: {
staff: { id: number; username: string; rank: number };
}) {
const t = await getTranslations("pages.admin.nav");
const initial = staff.username.charAt(0).toUpperCase();
return (
<aside
data-admin
className="max-lg:static sticky top-0 self-start h-full lg:h-screen overflow-y-auto bg-[var(--admin-sidebar-background)] text-[var(--admin-sidebar-text-readable)] shadow-xl flex flex-col"
>
<div className="flex items-center gap-3 px-4 py-5 border-b border-[var(--admin-border)]">
<span
className="flex-none w-10 h-10 rounded-xl grid place-items-center font-extrabold text-base text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] shadow-lg shadow-[var(--admin-accent)]/20"
aria-hidden
>
{initial}
</span>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm text-[var(--admin-sidebar-text-readable)] truncate">
{staff.username}
</div>
<span className="inline-block mt-0.5 text-[0.6rem] font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
{t("rankLabel", { rank: staff.rank })}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
</div>
</div>
<AdminSidebarNav />
<div className="px-3 py-3 border-t border-[var(--admin-border)]">
<Link
href="/"
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[var(--admin-sidebar-muted-readable)] text-xs font-medium hover:text-[var(--admin-sidebar-text-readable)] hover:bg-[var(--admin-accent)]/10 transition-all duration-150 no-underline"
>
<LogOut size={14} />
<span>{t("backToSite")}</span>
</Link>
</div>
</aside>
);
}