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
+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>
);
}