diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx new file mode 100644 index 0000000..2d7b48c --- /dev/null +++ b/src/app/(app)/einstellungen/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/(app)/hostgruppen/page.tsx b/src/app/(app)/hostgruppen/page.tsx new file mode 100644 index 0000000..d842e8b --- /dev/null +++ b/src/app/(app)/hostgruppen/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/(app)/hosts/page.tsx b/src/app/(app)/hosts/page.tsx new file mode 100644 index 0000000..7a0fb0a --- /dev/null +++ b/src/app/(app)/hosts/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/(app)/huellen-benutzer.ts b/src/app/(app)/huellen-benutzer.ts new file mode 100644 index 0000000..8b67f21 --- /dev/null +++ b/src/app/(app)/huellen-benutzer.ts @@ -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 { + return { name: "Florian Wilde", rolle: "ADMIN", herkunft: "authentik" }; +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..e6c31b5 --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -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 {children}; +} diff --git a/src/app/(app)/personen/page.tsx b/src/app/(app)/personen/page.tsx new file mode 100644 index 0000000..397ae49 --- /dev/null +++ b/src/app/(app)/personen/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/(app)/protokoll/page.tsx b/src/app/(app)/protokoll/page.tsx new file mode 100644 index 0000000..2f26c4c --- /dev/null +++ b/src/app/(app)/protokoll/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/(app)/schluessel/page.tsx b/src/app/(app)/schluessel/page.tsx new file mode 100644 index 0000000..382e2f7 --- /dev/null +++ b/src/app/(app)/schluessel/page.tsx @@ -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 ( +
+ +

+ Prototyp ansehen:{" "} + + Schlüsseldetail mit Beispieldaten + +

+
+ ); +} diff --git a/src/app/(app)/vorlagen/page.tsx b/src/app/(app)/vorlagen/page.tsx new file mode 100644 index 0000000..099c87f --- /dev/null +++ b/src/app/(app)/vorlagen/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/(app)/zuweisungen/page.tsx b/src/app/(app)/zuweisungen/page.tsx new file mode 100644 index 0000000..dd3594d --- /dev/null +++ b/src/app/(app)/zuweisungen/page.tsx @@ -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 ( + + ); +} diff --git a/src/components/huelle/Huelle.tsx b/src/components/huelle/Huelle.tsx new file mode 100644 index 0000000..f7a5563 --- /dev/null +++ b/src/components/huelle/Huelle.tsx @@ -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 ( +
+ + Zum Inhalt springen + + + + + + + } + > +
+ + + + + + +
+ +

+ v{VERSION} +

+
+
+
+ +
+
{children}
+
+
+ ); +} + +function BenutzerKasten({ benutzer }: { benutzer: HuellenBenutzer }) { + const kuerzel = benutzer.name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((t) => t[0]?.toUpperCase()) + .join(""); + return ( +
+ + {kuerzel} + +
+

{benutzer.name}

+

+ {ROLLEN_TEXT[benutzer.rolle]} · über {benutzer.herkunft} +

+
+
+ ); +} diff --git a/src/components/huelle/Navigation.tsx b/src/components/huelle/Navigation.tsx new file mode 100644 index 0000000..0528b86 --- /dev/null +++ b/src/components/huelle/Navigation.tsx @@ -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 ( + + ); +} + +function NavListe({ punkte, pfad }: { punkte: NavPunkt[]; pfad: string }) { + return ( +
    + {punkte.map(({ href, titel, icon: Icon }) => { + const aktiv = istAktiv(href, pfad); + return ( +
  • + + + {titel} + +
  • + ); + })} +
+ ); +} diff --git a/src/components/huelle/Platzhalterseite.tsx b/src/components/huelle/Platzhalterseite.tsx new file mode 100644 index 0000000..ec143a8 --- /dev/null +++ b/src/components/huelle/Platzhalterseite.tsx @@ -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 ( +
+ + } + titel={`Folgt in Phase ${phase}`} + text={ + <> + Diese Ansicht ist noch nicht gebaut + {issue && ( + <> + {" "} + – geplant in {issue} + + )} + . + + } + /> +
+ ); +} diff --git a/src/components/huelle/SeitenleistenRahmen.tsx b/src/components/huelle/SeitenleistenRahmen.tsx new file mode 100644 index 0000000..de7c0a6 --- /dev/null +++ b/src/components/huelle/SeitenleistenRahmen.tsx @@ -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(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) { + if ((e.target as HTMLElement).closest("a")) setOffen(false); + } + + return ( + <> + {/* Kopfleiste nur auf schmalen Bildschirmen */} +
+ + {kopf} +
+ + {offen && ( +
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. */} +
+ +
+ + ); +} diff --git a/src/components/huelle/index.ts b/src/components/huelle/index.ts new file mode 100644 index 0000000..b7c5ba5 --- /dev/null +++ b/src/components/huelle/index.ts @@ -0,0 +1,3 @@ +export { Huelle } from "./Huelle"; +export { Platzhalterseite } from "./Platzhalterseite"; +export { ROLLEN_TEXT, type HuellenBenutzer, type Rolle } from "./typen"; diff --git a/src/components/huelle/nav-punkte.ts b/src/components/huelle/nav-punkte.ts new file mode 100644 index 0000000..0537ba3 --- /dev/null +++ b/src/components/huelle/nav-punkte.ts @@ -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}/`); +} diff --git a/src/components/huelle/typen.ts b/src/components/huelle/typen.ts new file mode 100644 index 0000000..18fef4d --- /dev/null +++ b/src/components/huelle/typen.ts @@ -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 = { + ADMIN: "Admin", + OPERATOR: "Operator", + LESER: "Leser", +};