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:
@@ -0,0 +1,26 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/** Panel-Karte: 1-px-Rahmen in Linienfarbe, Radius 14 px, keine Schatten. */
|
||||
export function Karte({
|
||||
titel,
|
||||
aktion,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
titel?: React.ReactNode;
|
||||
aktion?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={clsx("bg-panel border-linie rounded-karte border", className)}>
|
||||
{titel ? (
|
||||
<header className="border-linie flex items-center justify-between gap-3 border-b px-5 py-3.5">
|
||||
<h2 className="text-base font-semibold">{titel}</h2>
|
||||
{aktion}
|
||||
</header>
|
||||
) : null}
|
||||
<div className="p-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
type Variante = "primaer" | "sekundaer" | "gefahr";
|
||||
|
||||
const VARIANTEN: Record<Variante, string> = {
|
||||
primaer: "bg-akzent text-akzent-text hover:opacity-90",
|
||||
sekundaer: "border border-linie bg-panel-hoch text-text hover:border-muted",
|
||||
gefahr: "border border-fehler text-fehler hover:bg-fehler/10",
|
||||
};
|
||||
|
||||
/** Ein echter `<button>`; 40 px hoch, am Handy 44 px (Tippfläche). */
|
||||
export function Knopf({
|
||||
variante = "primaer",
|
||||
className,
|
||||
...rest
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variante?: Variante }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...rest}
|
||||
className={clsx(
|
||||
"inline-flex h-11 items-center justify-center gap-2 rounded-lg px-4 text-sm font-semibold transition sm:h-10",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
VARIANTEN[variante],
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/**
|
||||
* Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut).
|
||||
* Dieselben Symbole wie in der CLI-Ausgabe.
|
||||
*/
|
||||
export type StatusArt = "erledigt" | "laeuft" | "fehler" | "offen";
|
||||
|
||||
export const STATUS_SYMBOL: Record<StatusArt, string> = {
|
||||
erledigt: "✓",
|
||||
laeuft: "◐",
|
||||
fehler: "✗",
|
||||
offen: "○",
|
||||
};
|
||||
|
||||
const STATUS_FARBE: Record<StatusArt, string> = {
|
||||
erledigt: "text-akzent",
|
||||
laeuft: "text-laeuft",
|
||||
fehler: "text-fehler",
|
||||
offen: "text-muted",
|
||||
};
|
||||
|
||||
export function Status({
|
||||
art,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
art: StatusArt;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={clsx("inline-flex items-center gap-1.5", STATUS_FARBE[art], className)}>
|
||||
<span aria-hidden="true" className="font-mono">
|
||||
{STATUS_SYMBOL[art]}
|
||||
</span>
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { THEME_COOKIE, type Theme } from "@/lib/theme";
|
||||
|
||||
export function ThemeUmschalter({ anfang }: { anfang: Theme }) {
|
||||
const [theme, setTheme] = useState<Theme>(anfang);
|
||||
const naechstes: Theme = theme === "dunkel" ? "hell" : "dunkel";
|
||||
|
||||
function umschalten() {
|
||||
document.documentElement.dataset["theme"] = naechstes;
|
||||
document.cookie = `${THEME_COOKIE}=${naechstes}; path=/; max-age=31536000; samesite=lax`;
|
||||
setTheme(naechstes);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={umschalten}
|
||||
className="text-muted hover:text-text inline-flex h-10 w-10 items-center justify-center rounded-lg"
|
||||
aria-label={naechstes === "hell" ? "Helles Theme einschalten" : "Dunkles Theme einschalten"}
|
||||
title={naechstes === "hell" ? "Helles Theme" : "Dunkles Theme"}
|
||||
>
|
||||
{theme === "dunkel" ? <Sun size={18} aria-hidden="true" /> : <Moon size={18} aria-hidden="true" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/**
|
||||
* Wortmarke nach Branding-Vorgabe: „Prox“ normal, „Deploy“ fett in Akzentfarbe, dahinter ein
|
||||
* Block-Cursor. Die Schrift ist immer JetBrains Mono.
|
||||
*/
|
||||
export function Wortmarke({ className, blinkt = false }: { className?: string; blinkt?: boolean }) {
|
||||
return (
|
||||
<span className={clsx("font-mono tracking-tight whitespace-nowrap", className)}>
|
||||
<span className="text-text">Prox</span>
|
||||
<span className="text-akzent font-bold">Deploy</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx("bg-akzent ml-[0.12em] inline-block h-[0.95em] w-[0.5em] align-[-0.12em]", {
|
||||
"pd-cursor-blinkt": blinkt,
|
||||
})}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Symbol „Launch-Node“ als Inline-SVG, damit es die Theme-Farben übernimmt. */
|
||||
export function Symbol({ className, titel }: { className?: string; titel?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
className={className}
|
||||
role={titel ? "img" : undefined}
|
||||
aria-hidden={titel ? undefined : true}
|
||||
>
|
||||
{titel ? <title>{titel}</title> : null}
|
||||
<path
|
||||
d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z"
|
||||
stroke="var(--farbe-text)"
|
||||
strokeWidth="3.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5"
|
||||
stroke="var(--farbe-akzent)"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M24 47 H40" stroke="var(--farbe-akzent)" strokeWidth="4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user