feat: Grundgerüst mit Anmeldung, Layout und Design-Tokens

Next.js 16, Prisma 7, Tailwind 4 und Vitest/Playwright, Konfiguration aus
Laufzettel übernommen, weil dort derselbe Stack erprobt ist.

Die Farben der Branding-Vorgabe stehen als Tokens in globals.css, das
helle Theme setzt dieselben Namen neu. Amber und Rot sind dort
abgedunkelt, sonst reicht der Kontrast nicht – ein Test prüft beide
Themes gegen 4,5:1.

Anmeldung mit lokalem Benutzer, Rolle je Anfrage aus der Datenbank,
Audit-Log per Trigger nur anhängbar. Geprüft im gebauten Stand auf
1440 px und 390 px.

Closes #001

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:23:41 +02:00
co-authored by Claude Opus 5.5
parent c3211d0fe2
commit 871bb3a77c
75 changed files with 10155 additions and 15 deletions
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { clsx } from "clsx";
import {
BookOpen,
FileStack,
History,
LayoutDashboard,
LogOut,
Menu,
Rocket,
Server,
Settings,
Layers,
X,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { istAktiv, type NavigationsEintrag } from "@/lib/navigation";
import type { Theme } from "@/lib/theme";
import { Symbol, Wortmarke } from "@/components/wortmarke";
import { ThemeUmschalter } from "@/components/theme-umschalter";
const SYMBOLE: Record<
NavigationsEintrag["symbol"],
React.ComponentType<{ size?: number; "aria-hidden"?: boolean }>
> = {
uebersicht: LayoutDashboard,
ausrollen: Rocket,
verlauf: History,
templates: Layers,
vorlagen: FileStack,
katalog: BookOpen,
server: Server,
einstellungen: Settings,
};
type Eigenschaften = {
eintraege: readonly NavigationsEintrag[];
benutzer: { anzeigename: string; rolle: string };
version: string;
theme: Theme;
serverUmschalter: React.ReactNode;
};
export function Seitenleiste({ eintraege, benutzer, version, theme, serverUmschalter }: Eigenschaften) {
const pfad = usePathname();
const router = useRouter();
const [offen, setOffen] = useState(false);
async function abmelden() {
await fetch("/api/v1/auth/abmelden", { method: "POST" });
router.replace("/anmelden");
router.refresh();
}
const inhalt = (
<div className="flex h-full flex-col gap-6 p-4">
<Link href="/" className="flex items-center gap-2.5 px-2 pt-1" onClick={() => setOffen(false)}>
<Symbol className="h-8 w-8 shrink-0" />
<Wortmarke className="text-lg" />
</Link>
{serverUmschalter}
<nav aria-label="Hauptnavigation" className="flex-1">
<ul className="flex flex-col gap-0.5">
{eintraege.map((eintrag) => {
const Icon = SYMBOLE[eintrag.symbol];
const aktiv = istAktiv(eintrag.pfad, pfad);
return (
<li key={eintrag.pfad}>
<Link
href={eintrag.pfad}
onClick={() => setOffen(false)}
aria-current={aktiv ? "page" : undefined}
className={clsx(
"flex h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium transition sm:h-10",
aktiv ? "bg-panel-hoch text-text" : "text-muted hover:text-text hover:bg-panel-hoch/60",
)}
>
<span className={aktiv ? "text-akzent" : undefined}>
<Icon size={18} aria-hidden={true} />
</span>
{eintrag.titel}
</Link>
</li>
);
})}
</ul>
</nav>
<div className="border-linie flex flex-col gap-3 border-t pt-4">
<div className="flex items-center justify-between gap-2 px-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{benutzer.anzeigename}</p>
<p className="text-muted text-xs">{benutzer.rolle === "ADMIN" ? "Administrator" : "Operator"}</p>
</div>
<div className="flex items-center">
<ThemeUmschalter anfang={theme} />
<button
type="button"
onClick={abmelden}
className="text-muted hover:text-text inline-flex h-10 w-10 items-center justify-center rounded-lg"
aria-label="Abmelden"
title="Abmelden"
>
<LogOut size={18} aria-hidden="true" />
</button>
</div>
</div>
<p className="text-muted px-2 font-mono text-xs">{version}</p>
</div>
</div>
);
return (
<>
{/* Handy: Kopfleiste mit Menüknopf, Navigation als Schublade. */}
<div className="border-linie bg-panel sticky top-0 z-30 flex h-14 items-center justify-between border-b px-4 lg:hidden">
<Link href="/" className="flex items-center gap-2">
<Symbol className="h-7 w-7" />
<Wortmarke />
</Link>
<button
type="button"
onClick={() => setOffen(true)}
className="inline-flex h-11 w-11 items-center justify-center rounded-lg"
aria-label="Navigation öffnen"
aria-expanded={offen}
aria-controls="seitenleiste"
>
<Menu size={22} aria-hidden="true" />
</button>
</div>
{offen ? (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
aria-hidden="true"
onClick={() => setOffen(false)}
/>
) : null}
<aside
id="seitenleiste"
className={clsx(
"border-linie bg-panel fixed inset-y-0 left-0 z-50 w-72 border-r transition-transform lg:sticky lg:top-0 lg:h-dvh lg:translate-x-0",
offen ? "translate-x-0" : "-translate-x-full",
)}
>
<button
type="button"
onClick={() => setOffen(false)}
className="absolute top-3 right-3 inline-flex h-11 w-11 items-center justify-center rounded-lg lg:hidden"
aria-label="Navigation schließen"
>
<X size={20} aria-hidden="true" />
</button>
{inhalt}
</aside>
</>
);
}