feat(ui): Huelle mit Seitenleiste und Platzhalterseiten (#002)

Die Huelle bekommt Benutzer und optional den aktiven Pfad als Props und
kennt weder Sitzung noch Datenbank; ohne Pfad ermittelt die Navigation
ihn per usePathname(), weil ein Layout den Pfad nicht kennt. Die
Benutzerermittlung steckt in huellenBenutzerErmitteln(), damit #004 nur
diese Funktion ersetzen muss.

Unter 1024 px klappt die Leiste ein; geschlossen ist sie invisible und
damit auch nicht per Tab erreichbar. Alle Navigationsziele haben eine
Platzhalterseite, damit nichts auf eine 404 fuehrt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:20:30 +02:00
co-authored by Claude Opus 5.5
parent da0607522e
commit 419e8ff8bd
17 changed files with 473 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
export const metadata: Metadata = { title: "Einstellungen" };
export default function Einstellungen() {
return (
<Platzhalterseite
titel="Einstellungen"
untertitel="Deployment-Key, eigene Vorlagen, Benachrichtigungen"
phase={3}
issue="#019"
/>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
export const metadata: Metadata = { title: "Hostgruppen" };
export default function Hostgruppen() {
return (
<Platzhalterseite
titel="Hostgruppen"
untertitel="Hosts bündeln, um Schlüssel gemeinsam zuzuweisen"
phase={1}
issue="#008"
/>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
export const metadata: Metadata = { title: "Hosts" };
export default function Hosts() {
return (
<Platzhalterseite
titel="Hosts"
untertitel="Verwaltete Server, Host-Keys und Erreichbarkeit"
phase={1}
issue="#008"
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { HuellenBenutzer } from "@/components/huelle/typen";
/**
* Liefert den Benutzer, den die Seitenleiste anzeigt.
*
* TODO (#004): durch die echte Sitzung ersetzen (Benutzer + Rolle aus der Datenbank,
* Herkunft „authentik“ oder „Notfall-Admin“) und ohne Sitzung zur Anmeldung umleiten.
* Bis dahin ein fester Beispielbenutzer, damit der Prototyp ohne Anmeldung läuft.
* async schon jetzt, damit sich die Aufrufer beim Umstieg nicht ändern müssen.
*/
export async function huellenBenutzerErmitteln(): Promise<HuellenBenutzer> {
return { name: "Florian Wilde", rolle: "ADMIN", herkunft: "authentik" };
}
+9
View File
@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
import { Huelle } from "@/components/huelle/Huelle";
import { huellenBenutzerErmitteln } from "./huellen-benutzer";
/** Alle angemeldeten Seiten teilen sich die Hülle mit Seitenleiste (#002). */
export default async function AppLayout({ children }: { children: ReactNode }) {
const benutzer = await huellenBenutzerErmitteln();
return <Huelle benutzer={benutzer}>{children}</Huelle>;
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
export const metadata: Metadata = { title: "Personen" };
export default function Personen() {
return (
<Platzhalterseite titel="Personen" untertitel="Wem welche Schlüssel gehören" phase={2} issue="#013" />
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
export const metadata: Metadata = { title: "Audit-Log" };
export default function Protokoll() {
return (
<Platzhalterseite
titel="Audit-Log"
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen"
phase={5}
issue="#027"
/>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
export const metadata: Metadata = { title: "Schlüssel" };
export default function Schluessel() {
return (
<div className="flex flex-col gap-5">
<Platzhalterseite
titel="Schlüssel"
untertitel="SSH-Schlüssel erzeugen, importieren und ausrollen"
phase={2}
issue="#012"
/>
<p className="text-muted text-[14px]">
Prototyp ansehen:{" "}
<Link href="/schluessel/beispiel" className="text-accent font-medium hover:underline">
Schlüsseldetail mit Beispieldaten
</Link>
</p>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
export const metadata: Metadata = { title: "Vorlagen" };
export default function Vorlagen() {
return (
<Platzhalterseite
titel="Vorlagen"
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup"
phase={3}
issue="#019"
/>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
export const metadata: Metadata = { title: "Zuweisungen" };
export default function Zuweisungen() {
return (
<Platzhalterseite
titel="Zuweisungen"
untertitel="Welcher Schlüssel auf welchem Host-Konto gilt"
phase={2}
issue="#016"
/>
);
}
+91
View File
@@ -0,0 +1,91 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { Bildmarke, Kurzname, Marke, Wortmarke } from "@/components/marke";
import { VERSION } from "@/lib/version";
import { Navigation } from "./Navigation";
import { SeitenleistenRahmen } from "./SeitenleistenRahmen";
import { ROLLEN_TEXT, type HuellenBenutzer } from "./typen";
/**
* Grundlayout (#002): feste Seitenleiste links (248 px), Hauptbereich rechts.
* Benutzer und aktiver Pfad kommen als Props – die Hülle kennt weder Sitzung noch
* Datenbank, damit sie in Tests und Prototypen ohne Anmeldung rendert.
*/
export function Huelle({
benutzer,
aktiverPfad,
children,
}: {
benutzer: HuellenBenutzer;
/** Ohne Angabe ermittelt die Navigation den Pfad im Browser. */
aktiverPfad?: string;
children: ReactNode;
}) {
return (
<div className="bg-bg min-h-dvh lg:flex">
<a
href="#inhalt"
className="bg-accent text-bg rounded-box sr-only z-[60] px-4 py-3 font-semibold focus:not-sr-only focus:fixed focus:top-3 focus:left-3"
>
Zum Inhalt springen
</a>
<SeitenleistenRahmen
kopf={
<Link href="/" className="rounded-box flex min-h-11 items-center gap-2 px-1">
<Bildmarke groesse={28} />
<Wortmarke groesse={19} />
</Link>
}
>
<div className="flex h-full flex-col overflow-y-auto px-4 pt-6 pb-4">
<Link
href="/"
className="rounded-box mb-8 flex items-center px-2 py-1"
aria-label="SSHKeyVault – Übersicht"
>
<Marke bildGroesse={34} textGroesse={22} />
</Link>
<Navigation aktiverPfad={aktiverPfad} />
<div className="mt-auto pt-6">
<BenutzerKasten benutzer={benutzer} />
<p className="text-muted mt-3 px-2 font-mono text-[11.5px]">
<Kurzname /> v{VERSION}
</p>
</div>
</div>
</SeitenleistenRahmen>
<main id="inhalt" tabIndex={-1} className="min-w-0 flex-1 outline-none">
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-8 sm:py-8 xl:px-10">{children}</div>
</main>
</div>
);
}
function BenutzerKasten({ benutzer }: { benutzer: HuellenBenutzer }) {
const kuerzel = benutzer.name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((t) => t[0]?.toUpperCase())
.join("");
return (
<div className="border-border bg-surface rounded-box flex items-center gap-3 border p-3">
<span
aria-hidden
className="bg-raised text-accent font-display flex size-9 shrink-0 items-center justify-center rounded-full text-[13px] font-semibold"
>
{kuerzel}
</span>
<div className="min-w-0 text-[13px] leading-snug">
<p className="text-text truncate text-[14px] font-medium">{benutzer.name}</p>
<p className="text-muted truncate">
{ROLLEN_TEXT[benutzer.rolle]} · über {benutzer.herkunft}
</p>
</div>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { clsx } from "clsx";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { HAUPTNAVIGATION, istAktiv, NEBENNAVIGATION, type NavPunkt } from "./nav-punkte";
/**
* Navigation der Seitenleiste. Client-Komponente, weil ein Layout den aktuellen Pfad
* nicht kennt: Ohne `aktiverPfad` nimmt sie ihn aus usePathname(); Seiten oder Tests
* können ihn ausdrücklich vorgeben.
*/
export function Navigation({ aktiverPfad }: { aktiverPfad?: string }) {
const routerPfad = usePathname();
const pfad = aktiverPfad ?? routerPfad ?? "/";
return (
<nav aria-label="Hauptnavigation" className="flex flex-col gap-1">
<NavListe punkte={HAUPTNAVIGATION} pfad={pfad} />
<hr className="border-border mx-2 my-3" />
<NavListe punkte={NEBENNAVIGATION} pfad={pfad} />
</nav>
);
}
function NavListe({ punkte, pfad }: { punkte: NavPunkt[]; pfad: string }) {
return (
<ul className="flex flex-col gap-1">
{punkte.map(({ href, titel, icon: Icon }) => {
const aktiv = istAktiv(href, pfad);
return (
<li key={href}>
<Link
href={href}
aria-current={aktiv ? "page" : undefined}
className={clsx(
"rounded-box flex min-h-11 items-center gap-3 px-3 text-[14.5px] transition-colors",
aktiv
? "bg-raised text-accent font-semibold"
: "text-muted hover:bg-raised/60 hover:text-text font-medium",
)}
>
<Icon aria-hidden className="size-[18px] shrink-0" strokeWidth={aktiv ? 2.2 : 1.8} />
{titel}
</Link>
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,42 @@
import { Construction } from "lucide-react";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
/**
* Für Navigationsziele, die erst in späteren Phasen gebaut werden (#002) – damit die
* Navigation nie auf eine 404 führt.
*/
export function Platzhalterseite({
titel,
untertitel,
phase,
issue,
}: {
titel: string;
untertitel?: string;
phase: number;
/** Issue-Nummer, z. B. "#008". */
issue?: string;
}) {
return (
<div className="flex flex-col gap-7">
<Seitenkopf titel={titel} untertitel={untertitel} />
<Leerzustand
icon={<Construction className="size-5" />}
titel={`Folgt in Phase ${phase}`}
text={
<>
Diese Ansicht ist noch nicht gebaut
{issue && (
<>
{" "}
– geplant in <span className="font-mono">{issue}</span>
</>
)}
.
</>
}
/>
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import { clsx } from "clsx";
import { Menu, X } from "lucide-react";
import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
/**
* Schaltet die Seitenleiste unter 1024 px ein und aus (#002). Der Inhalt der Leiste kommt
* als Server-gerenderte children; nur das Auf/Zu lebt im Browser.
*
* Geschlossen ist die Leiste mobil `invisible` und damit auch aus der Tab-Reihenfolge –
* nur verschieben würde unsichtbare Links fokussierbar lassen.
*/
export function SeitenleistenRahmen({ kopf, children }: { kopf: ReactNode; children: ReactNode }) {
const [offen, setOffen] = useState(false);
const knopf = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!offen) return;
function taste(e: KeyboardEvent) {
if (e.key === "Escape") {
setOffen(false);
knopf.current?.focus();
}
}
window.addEventListener("keydown", taste);
return () => window.removeEventListener("keydown", taste);
}, [offen]);
// Ein Klick auf einen Link in der Leiste navigiert – danach soll das Menü zu sein.
function klickInLeiste(e: MouseEvent<HTMLElement>) {
if ((e.target as HTMLElement).closest("a")) setOffen(false);
}
return (
<>
{/* Kopfleiste nur auf schmalen Bildschirmen */}
<div className="border-border bg-sidebar sticky top-0 z-30 flex h-14 items-center gap-2 border-b px-2 lg:hidden">
<button
ref={knopf}
type="button"
onClick={() => setOffen((o) => !o)}
aria-expanded={offen}
aria-controls="seitenleiste"
aria-label={offen ? "Menü schließen" : "Menü öffnen"}
className="text-text hover:bg-raised rounded-box inline-flex size-11 items-center justify-center"
>
{offen ? <X className="size-5" aria-hidden /> : <Menu className="size-5" aria-hidden />}
</button>
{kopf}
</div>
{offen && (
<div
aria-hidden
className="fixed inset-0 z-40 bg-black/60 lg:hidden"
onClick={() => setOffen(false)}
/>
)}
{/* Ab 1024 px eine eigene Spalte über die volle Seitenhöhe; die Leiste darin klebt
oben (sticky), damit Hintergrund und Rahmen auch bei langen Seiten durchlaufen. */}
<div className="lg:border-border lg:bg-sidebar lg:w-[var(--seitenleiste-breite)] lg:shrink-0 lg:border-r">
<aside
id="seitenleiste"
onClick={klickInLeiste}
className={clsx(
"border-border bg-sidebar fixed inset-y-0 left-0 z-50 flex w-[280px] max-w-[85vw] flex-col border-r",
"transition-[transform,visibility] duration-200 lg:visible lg:translate-x-0 lg:transition-none",
"lg:sticky lg:top-0 lg:bottom-auto lg:h-dvh lg:w-full lg:max-w-none lg:border-r-0",
offen ? "visible translate-x-0" : "invisible -translate-x-full",
)}
>
{/* Der Abdunkler verdeckt den Menü-Button oben, daher ein eigener Schließen-Knopf. */}
<button
type="button"
onClick={() => {
setOffen(false);
knopf.current?.focus();
}}
aria-label="Menü schließen"
className="text-muted hover:bg-raised hover:text-text rounded-box absolute top-4 right-2 inline-flex size-11 items-center justify-center lg:hidden"
>
<X className="size-5" aria-hidden />
</button>
{children}
</aside>
</div>
</>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { Huelle } from "./Huelle";
export { Platzhalterseite } from "./Platzhalterseite";
export { ROLLEN_TEXT, type HuellenBenutzer, type Rolle } from "./typen";
+37
View File
@@ -0,0 +1,37 @@
import {
Boxes,
KeyRound,
LayoutDashboard,
LayoutTemplate,
Network,
ScrollText,
Server,
Settings,
Users,
type LucideIcon,
} from "lucide-react";
export type NavPunkt = { href: string; titel: string; icon: LucideIcon };
/** Hauptnavigation (#002). Reihenfolge folgt dem Arbeitsablauf: Hosts → Schlüssel → Zuweisen. */
export const HAUPTNAVIGATION: NavPunkt[] = [
{ href: "/", titel: "Übersicht", icon: LayoutDashboard },
{ href: "/hosts", titel: "Hosts", icon: Server },
{ href: "/hostgruppen", titel: "Hostgruppen", icon: Boxes },
{ href: "/schluessel", titel: "Schlüssel", icon: KeyRound },
{ href: "/personen", titel: "Personen", icon: Users },
{ href: "/zuweisungen", titel: "Zuweisungen", icon: Network },
{ href: "/vorlagen", titel: "Vorlagen", icon: LayoutTemplate },
];
/** Unter dem Trenner: Nachvollziehen und Verwalten statt Tagesgeschäft. */
export const NEBENNAVIGATION: NavPunkt[] = [
{ href: "/protokoll", titel: "Audit-Log", icon: ScrollText },
{ href: "/einstellungen", titel: "Einstellungen", icon: Settings },
];
/** „/“ nur exakt, sonst zählt auch jede Unterseite (/schluessel/abc → Schlüssel). */
export function istAktiv(href: string, pfad: string): boolean {
if (href === "/") return pfad === "/";
return pfad === href || pfad.startsWith(`${href}/`);
}
+13
View File
@@ -0,0 +1,13 @@
export type Rolle = "ADMIN" | "OPERATOR" | "LESER";
/**
* Was die Hülle vom angemeldeten Benutzer wissen muss – bewusst nur Anzeigedaten,
* damit die Hülle unabhängig von Sitzung und Datenbank bleibt (#002, #004).
*/
export type HuellenBenutzer = { name: string; rolle: Rolle; herkunft: string };
export const ROLLEN_TEXT: Record<Rolle, string> = {
ADMIN: "Admin",
OPERATOR: "Operator",
LESER: "Leser",
};