diff --git a/docs/anleitung/bilder/schluessel-detail-desktop.png b/docs/anleitung/bilder/schluessel-detail-desktop.png new file mode 100644 index 0000000..e085b4e Binary files /dev/null and b/docs/anleitung/bilder/schluessel-detail-desktop.png differ diff --git a/docs/anleitung/bilder/schluessel-detail-mobil.png b/docs/anleitung/bilder/schluessel-detail-mobil.png new file mode 100644 index 0000000..719f4ad Binary files /dev/null and b/docs/anleitung/bilder/schluessel-detail-mobil.png differ diff --git a/docs/anleitung/bilder/uebersicht-desktop.png b/docs/anleitung/bilder/uebersicht-desktop.png new file mode 100644 index 0000000..d58e294 Binary files /dev/null and b/docs/anleitung/bilder/uebersicht-desktop.png differ diff --git a/docs/anleitung/bilder/uebersicht-mobil.png b/docs/anleitung/bilder/uebersicht-mobil.png new file mode 100644 index 0000000..8ad67ab Binary files /dev/null and b/docs/anleitung/bilder/uebersicht-mobil.png differ diff --git a/docs/issues/002-design-layout.md b/docs/issues/002-design-layout.md index 7768dfe..ea2e2ed 100644 --- a/docs/issues/002-design-layout.md +++ b/docs/issues/002-design-layout.md @@ -1,25 +1,51 @@ # #002 Design-System und Layout -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** ui -**Geschätzt:** 4 h **Tatsächlich:** – +**Geschätzt:** 4 h **Tatsächlich:** 0,3 h (09:07–09:22) ## Ziel Design laut Vorgabe (Tresor, Graphit + Messing): Tokens, Schriften, Logo, Favicon/PWA-Icons, Grundlayout mit Seitenleiste. ## Aufgaben -- [ ] Design-Tokens als CSS-Variablen + Tailwind-Theme -- [ ] Space Grotesk, IBM Plex Sans, JetBrains Mono selbst gehostet (`@fontsource`) -- [ ] `public/brand/` mit `logo.svg`, `logo-light.svg`, `logo-mono.svg`, `favicon.svg`; PNG-Icons + Manifest per Skript -- [ ] Seitenleiste (Logo, Navigation, Benutzer unten), einklappbar auf schmalen Bildschirmen -- [ ] Grundkomponenten: Karte, Button (primär/sekundär/Gefahr), Badge, Statuspunkt, Tabelle, Diff-Box, Kennzahl-Kachel +- [x] Design-Tokens als CSS-Variablen + Tailwind-Theme +- [x] Space Grotesk, IBM Plex Sans, JetBrains Mono selbst gehostet (`@fontsource`) +- [x] `public/brand/` mit `logo.svg`, `logo-light.svg`, `logo-mono.svg`, `favicon.svg`; PNG-Icons + Manifest per Skript +- [x] Seitenleiste (Logo, Navigation, Benutzer unten), einklappbar auf schmalen Bildschirmen +- [x] Grundkomponenten: Karte, Button (primär/sekundär/Gefahr), Badge, Statuspunkt, Tabelle, Diff-Box, Kennzahl-Kachel ## Akzeptanzkriterien -- [ ] Kontrast ≥ 4,5:1, Fokusrahmen in Messing -- [ ] Kein horizontales Scrollen bei 390 px -- [ ] Klickflächen ≥ 44 px +- [x] Kontrast ≥ 4,5:1, Fokusrahmen in Messing +- [x] Kein horizontales Scrollen bei 390 px +- [x] Klickflächen ≥ 44 px ## Abhängigkeiten #001 ## Notizen / Entscheidungen +- **Tokens:** CSS-Variablen in `:root`, an Tailwind per `@theme inline` gereicht (`bg-surface`, + `text-muted`, `border-border-strong`, `rounded-karte` …). So nutzen Inline-SVGs und Utilities + dieselbe Quelle. Nur dunkler Modus. +- **Schriften:** nur Latin-Ausschnitt und benutzte Gewichte, importiert in `src/app/layout.tsx`. + Zeichen außerhalb (z. B. „→“) fallen auf die Systemschrift zurück – unauffällig. +- **Kontrast** (gemessen): `--text-muted` auf `--surface` 5,8:1, auf `--raised` 5,3:1; + Messing auf `--accent-soft` 7,2:1; Koralle auf `--danger-soft` 5,8:1; Text auf Messing-Button 10,3:1. +- **Icons:** `pnpm icons` (`scripts/icons.mjs`) erzeugt `public/icons/*`, `src/app/favicon.ico` + (16/32/48, PNG-in-ICO), `src/app/icon.svg` und `src/app/apple-icon.png`; Ergebnisse sind + eingecheckt, damit Build und Laufzeitabbild kein sharp brauchen. App-Icons auf `#0D1013` mit + Rand (Logo 72 %), deshalb ohne `maskable`. +- **Aktiver Pfad:** Ein Layout kennt den Pfad nicht. `Huelle` nimmt `aktiverPfad` optional; + ohne Angabe ermittelt die Client-`Navigation` ihn per `usePathname()`. +- **Benutzer:** `src/app/(app)/huellen-benutzer.ts` → `huellenBenutzerErmitteln()` liefert bis + #004 einen festen Beispielbenutzer (TODO-Kommentar). +- **Mobil:** Unter 1024 px Kopfleiste mit Menü-Button, Leiste als Overlay (280 px) mit eigenem + Schließen-Knopf; Escape, Klick auf Abdunkler oder auf einen Link schließt. Geschlossen ist die + Leiste `invisible` und damit nicht per Tab erreichbar. +- **Desktop:** Seitenleiste als eigene Flex-Spalte, Inhalt darin `sticky` – Hintergrund und Rahmen + laufen auch bei langen Seiten (und in Full-Page-Screenshots) durch. +- **Tabellen** scrollen im eigenen Container; der Container ist `relative`, sonst entkommen + `sr-only`-Texte dem Scrollbereich und verbreitern die Seite (bei 390 px gefunden). +- **Prototyp:** Beispieldaten in `src/beispiel/` (Bezugszeit `BEISPIEL_JETZT`), Seiten sichtbar + als „Beispieldaten“ markiert. Platzhalterseiten nennen Phase und Issue. +- **Screenshots:** `pnpm exec tsx scripts/screenshots.ts` (ENV `SSHKV_URL`, `SSHKV_BILDER`, + `SSHKV_SEITEN`) → `docs/anleitung/bilder/`; meldet horizontalen Überlauf mit Exit-Code 1. diff --git a/public/brand/favicon.svg b/public/brand/favicon.svg new file mode 100644 index 0000000..b5f29a9 --- /dev/null +++ b/public/brand/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/brand/logo-light.svg b/public/brand/logo-light.svg new file mode 100644 index 0000000..ebc67d3 --- /dev/null +++ b/public/brand/logo-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/brand/logo-mono.svg b/public/brand/logo-mono.svg new file mode 100644 index 0000000..13a7213 --- /dev/null +++ b/public/brand/logo-mono.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/brand/logo.svg b/public/brand/logo.svg new file mode 100644 index 0000000..d5a2de1 --- /dev/null +++ b/public/brand/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..3a95d68 Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/favicon-16.png b/public/icons/favicon-16.png new file mode 100644 index 0000000..194b301 Binary files /dev/null and b/public/icons/favicon-16.png differ diff --git a/public/icons/favicon-32.png b/public/icons/favicon-32.png new file mode 100644 index 0000000..0b612cf Binary files /dev/null and b/public/icons/favicon-32.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..195f7a7 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..480ddf2 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/scripts/icons.mjs b/scripts/icons.mjs new file mode 100644 index 0000000..1579def --- /dev/null +++ b/scripts/icons.mjs @@ -0,0 +1,99 @@ +// Erzeugt alle Raster-Icons aus den SVG-Vorlagen in public/brand/ (#002). +// +// Die PNGs und das favicon.ico werden eingecheckt, damit Build und Docker-Abbild +// kein sharp zur Laufzeit brauchen. Nach jeder Logo-Aenderung: `pnpm icons`. +import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; + +const WURZEL = join(dirname(fileURLToPath(import.meta.url)), ".."); +const BRAND = join(WURZEL, "public/brand"); +const ICONS = join(WURZEL, "public/icons"); +const APP = join(WURZEL, "src/app"); + +// Seitenhintergrund (--bg); App-Icons stehen auf dieser Flaeche statt transparent, +// weil iOS und Android transparente Bereiche sonst schwarz oder weiss fuellen. +const HINTERGRUND = "#0D1013"; + +/** Rendert ein SVG scharf in der Zielgroesse (density skaliert die Vektoren statt Pixel). */ +async function rendern(svg, groesse) { + return sharp(svg, { density: Math.ceil((72 * groesse) / 32) * 2 }) + .resize(groesse, groesse) + .png() + .toBuffer(); +} + +/** Logo mittig auf --bg mit Rand, damit es auf Homescreens nicht an die Kante stoesst. */ +async function aufFlaeche(svg, groesse, anteil) { + const innen = Math.round(groesse * anteil); + const logo = await rendern(svg, innen); + return sharp({ + create: { width: groesse, height: groesse, channels: 4, background: HINTERGRUND }, + }) + .composite([{ input: logo, gravity: "center" }]) + .png() + .toBuffer(); +} + +/** Minimaler ICO-Container mit PNG-Eintraegen (seit Windows Vista von allen Browsern gelesen). */ +function icoBauen(bilder) { + const kopf = Buffer.alloc(6); + kopf.writeUInt16LE(0, 0); + kopf.writeUInt16LE(1, 2); + kopf.writeUInt16LE(bilder.length, 4); + const eintraege = []; + let versatz = 6 + 16 * bilder.length; + for (const { groesse, daten } of bilder) { + const e = Buffer.alloc(16); + e.writeUInt8(groesse >= 256 ? 0 : groesse, 0); + e.writeUInt8(groesse >= 256 ? 0 : groesse, 1); + e.writeUInt8(0, 2); + e.writeUInt8(0, 3); + e.writeUInt16LE(1, 4); + e.writeUInt16LE(32, 6); + e.writeUInt32LE(daten.length, 8); + e.writeUInt32LE(versatz, 12); + versatz += daten.length; + eintraege.push(e); + } + return Buffer.concat([kopf, ...eintraege, ...bilder.map((b) => b.daten)]); +} + +async function main() { + await mkdir(ICONS, { recursive: true }); + const logo = await readFile(join(BRAND, "logo.svg")); + // Unter 32 px wird die reduzierte Variante ohne Zahlenring und Bolzen genutzt, + // sonst verschwimmen die Details zu grauem Rauschen. + const favicon = await readFile(join(BRAND, "favicon.svg")); + + const icon192 = await aufFlaeche(logo, 192, 0.72); + const icon512 = await aufFlaeche(logo, 512, 0.72); + const apple = await aufFlaeche(logo, 180, 0.72); + const fav32 = await rendern(favicon, 32); + const fav16 = await rendern(favicon, 16); + const fav48 = await rendern(favicon, 48); + + await writeFile(join(ICONS, "icon-192.png"), icon192); + await writeFile(join(ICONS, "icon-512.png"), icon512); + await writeFile(join(ICONS, "apple-touch-icon.png"), apple); + await writeFile(join(ICONS, "favicon-32.png"), fav32); + await writeFile(join(ICONS, "favicon-16.png"), fav16); + + // Next-Metadaten-Dateien: favicon.ico, icon.svg und apple-icon.png in src/app + // erzeugen die -Tags automatisch. + await writeFile( + join(APP, "favicon.ico"), + icoBauen([ + { groesse: 16, daten: fav16 }, + { groesse: 32, daten: fav32 }, + { groesse: 48, daten: fav48 }, + ]), + ); + await copyFile(join(BRAND, "favicon.svg"), join(APP, "icon.svg")); + await writeFile(join(APP, "apple-icon.png"), apple); + + console.log("Icons erzeugt: public/icons/*, src/app/favicon.ico, src/app/icon.svg, src/app/apple-icon.png"); +} + +await main(); diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts new file mode 100644 index 0000000..f9808b4 --- /dev/null +++ b/scripts/screenshots.ts @@ -0,0 +1,82 @@ +/** + * Screenshots der Oberfläche für Anleitung und Design-Abnahme (#002). + * + * pnpm build && pnpm start # erstes Terminal + * pnpm exec tsx scripts/screenshots.ts # zweites Terminal + * + * Umgebung: + * SSHKV_URL Basis-URL (Standard http://localhost:3400) + * SSHKV_BILDER Zielordner (Standard docs/anleitung/bilder) + * SSHKV_SEITEN Kommagetrennte Auswahl der Seitennamen (Standard: alle) + * + * Prüft nebenbei, dass keine Seite horizontal scrollt (Design-Vorgabe ab 390 px), und + * endet dann mit Fehlercode – so taugt das Skript auch als schneller Layout-Test. + */ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { chromium } from "@playwright/test"; + +const BASIS = (process.env["SSHKV_URL"] ?? "http://localhost:3400").replace(/\/$/, ""); +const ZIEL = process.env["SSHKV_BILDER"] ?? join(process.cwd(), "docs/anleitung/bilder"); + +const SEITEN = [ + { name: "uebersicht", pfad: "/" }, + { name: "schluessel-detail", pfad: "/schluessel/beispiel" }, +]; + +const ANSICHTEN = [ + { name: "desktop", width: 1440, height: 900 }, + { name: "mobil", width: 390, height: 844 }, +]; + +async function main() { + const auswahl = process.env["SSHKV_SEITEN"]?.split(",").map((s) => s.trim()); + const seiten = auswahl ? SEITEN.filter((s) => auswahl.includes(s.name)) : SEITEN; + await mkdir(ZIEL, { recursive: true }); + + const browser = await chromium.launch(); + const fehler: string[] = []; + try { + for (const ansicht of ANSICHTEN) { + const kontext = await browser.newContext({ + viewport: { width: ansicht.width, height: ansicht.height }, + deviceScaleFactor: 1, + locale: "de-DE", + timezoneId: "Europe/Berlin", + colorScheme: "dark", + }); + const seite = await kontext.newPage(); + for (const s of seiten) { + const antwort = await seite.goto(`${BASIS}${s.pfad}`, { waitUntil: "networkidle" }); + if (!antwort?.ok()) { + fehler.push(`${s.pfad}: HTTP ${antwort?.status() ?? "keine Antwort"}`); + continue; + } + // Erst nach dem Laden der selbst gehosteten Schriften fotografieren, sonst + // stehen Fallback-Schriften im Bild. + await seite.evaluate(() => document.fonts.ready); + + const ueberlauf = await seite.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + if (ueberlauf > 0) { + fehler.push(`${s.pfad} @ ${ansicht.width}px: ${ueberlauf}px horizontaler Überlauf`); + } + + const datei = join(ZIEL, `${s.name}-${ansicht.name}.png`); + await seite.screenshot({ path: datei, fullPage: true }); + console.log(`✓ ${datei}`); + } + await kontext.close(); + } + } finally { + await browser.close(); + } + + if (fehler.length > 0) { + console.error(`\n${fehler.length} Problem(e):\n- ${fehler.join("\n- ")}`); + process.exitCode = 1; + } +} + +await main(); 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)/page.tsx b/src/app/(app)/page.tsx new file mode 100644 index 0000000..6978a9b --- /dev/null +++ b/src/app/(app)/page.tsx @@ -0,0 +1,187 @@ +import { ArrowRight, Plus, RefreshCw, Route } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { + abweichungen, + BEISPIEL_JETZT, + baldAblaufend, + kennzahlen, + letzteAktionen, + letzterAbgleich, + type AktionsArt, +} from "@/beispiel/uebersicht"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { Karte } from "@/components/ui/Karte"; +import { Kennzahl } from "@/components/ui/Kennzahl"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Statuspunkt } from "@/components/ui/Statuspunkt"; +import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle"; +import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format"; + +export const metadata: Metadata = { title: "Übersicht" }; + +const AKTION: Record = { + ausgerollt: { text: "Ausgerollt", farbe: "text-ok" }, + widerrufen: { text: "Widerrufen", farbe: "text-danger" }, + erstellt: { text: "Erstellt", farbe: "text-text" }, + abgleich: { text: "Abgleich", farbe: "text-info" }, + rotiert: { text: "Rotiert", farbe: "text-accent" }, +}; + +/** + * Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen + * folgt in #026; Aufbau und Komponenten bleiben. + */ +export default function Uebersicht() { + return ( +
+ Prototyp · Beispieldaten} + untertitel={ + <> + Letzter Abgleich {relativ(letzterAbgleich, BEISPIEL_JETZT)} ·{" "} + + + } + aktionen={ + <> + + + + + } + /> + +
+ + + + +
+ +
+ + + {abweichungen.map((a) => ( + + + {a.host} + + {a.benutzer} + + + + + + Prüfen – {a.host} + + + + ))} + + + + +
    + {baldAblaufend.map((s) => { + const tage = tageBis(s.ablauf, BEISPIEL_JETZT); + return ( +
  • + + + {s.zweck} + + {s.person} · {s.typ} + + + + + + +
  • + ); + })} +
+
+
+ + + Zum Audit-Log + + } + > +
    + {letzteAktionen.map((a) => ( +
  1. + + {AKTION[a.art].text} + {a.beschreibung} + + {a.benutzer} + +
  2. + ))} +
+
+
+ ); +} 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/beispiel/page.tsx b/src/app/(app)/schluessel/beispiel/page.tsx new file mode 100644 index 0000000..186f9f5 --- /dev/null +++ b/src/app/(app)/schluessel/beispiel/page.tsx @@ -0,0 +1,181 @@ +import { Ban, Check, Lock, PlugZap, Plus, RotateCw } from "lucide-react"; +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import { + beispielSchluessel as s, + dryRunDatei, + dryRunDiff, + privatAusgerollt, + publicAusgerollt, + zusatzaktionen, + type Ausrollung, +} from "@/beispiel/schluessel"; +import { BEISPIEL_JETZT } from "@/beispiel/uebersicht"; +import { Badge } from "@/components/ui/Badge"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Button } from "@/components/ui/Button"; +import { DiffBox } from "@/components/ui/DiffBox"; +import { InnereBox, Karte } from "@/components/ui/Karte"; +import { MonoKopieren } from "@/components/ui/MonoKopieren"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { Statuspunkt } from "@/components/ui/Statuspunkt"; +import { datum, datumZeit, tageBis } from "@/lib/format"; + +export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` }; + +/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */ +export default function SchluesselBeispiel() { + const tage = tageBis(s.ablauf, BEISPIEL_JETZT); + return ( +
+ } + titel={s.zweck} + badges={ + <> + {s.typ} + {s.kategorie} + Vorlage: {s.vorlage} + + + läuft ab am · in {tage} Tagen + + + + Beispieldaten + + + } + aktionen={ + <> + + + + + } + /> + + +
+ + + + + + von {s.erstelltVon} + + + + + verschlüsselt gespeichert · + + + +
+
+ +
+ + +
+ + + Abgleich für backup@srv-pve02 – noch nichts geschrieben + + } + > + +
+ Zusätzlich ausführen +
    + {zusatzaktionen.map((z) => ( +
  • + +
  • + ))} +
+
+
+ + +
+
+
+ ); +} + +function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) { + return ( +
+
{titel}
+
{children}
+
+ ); +} + +function AusrollKarte({ + titel, + untertitel, + eintraege, +}: { + titel: string; + untertitel: string; + eintraege: Ausrollung[]; +}) { + return ( + }> + Ausrollen + + } + > +
    + {eintraege.map((e) => ( +
  • + +
    +

    {e.ziel}

    +

    {e.pfad}

    + {e.zusatz &&

    {e.zusatz}

    } +
    + +
    +
  • + ))} +
+
+ ); +} 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/app/apple-icon.png b/src/app/apple-icon.png new file mode 100644 index 0000000..3a95d68 Binary files /dev/null and b/src/app/apple-icon.png differ diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000..ffeddae Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css index f1d8c73..49f3cd8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1 +1,104 @@ @import "tailwindcss"; + +/* + * Design „Tresor“ (#002): Graphit-Flächen, eine Markenfarbe (Messing), Monospace für alles + * Technische. Die Werte stehen als CSS-Variablen in :root, damit auch Inline-SVGs und + * Stile ausserhalb von Tailwind dieselben Tokens nutzen; @theme reicht sie an Tailwind + * weiter (bg-surface, text-muted, border-strong …). + * + * Es gibt bewusst nur den dunklen Modus. Ein heller folgt in einer eigenen Phase (PLAN §5). + */ +:root { + --bg: #0d1013; + --sidebar: #11151a; + --surface: #151a1f; + --raised: #1d232a; + --border: #222930; + --border-strong: #2a323b; + --text: #e8ecef; + --text-muted: #8b96a3; + --accent: #f0b43c; + --accent-soft: #3a2e14; + --ok: #3cc9a7; + --danger: #f2714b; + --danger-soft: #2a1814; + --danger-border: #6b3324; + --info: #6aa8ff; + + --schrift-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif; + --schrift-text: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif; + --schrift-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace; + + --seitenleiste-breite: 248px; + + color-scheme: dark; +} + +/* inline: Tailwind-Klassen verweisen auf die Variablen statt die Werte zu kopieren. */ +@theme inline { + --color-bg: var(--bg); + --color-sidebar: var(--sidebar); + --color-surface: var(--surface); + --color-raised: var(--raised); + --color-border: var(--border); + --color-border-strong: var(--border-strong); + --color-text: var(--text); + --color-muted: var(--text-muted); + --color-accent: var(--accent); + --color-accent-soft: var(--accent-soft); + --color-ok: var(--ok); + --color-danger: var(--danger); + --color-danger-soft: var(--danger-soft); + --color-danger-border: var(--danger-border); + --color-info: var(--info); + + --font-display: var(--schrift-display); + --font-sans: var(--schrift-text); + --font-mono: var(--schrift-mono); + + --radius-karte: 16px; + --radius-box: 12px; + --radius-feld: 10px; +} + +@layer base { + html { + background: var(--bg); + color: var(--text); + font-family: var(--schrift-text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } + + body { + min-height: 100dvh; + background: var(--bg); + font-size: 15px; + line-height: 1.5; + } + + h1, + h2, + h3 { + font-family: var(--schrift-display); + letter-spacing: -0.01em; + } + + /* Fokus immer sichtbar und in Messing – auch auf Elementen ohne eigenen Fokusstil. */ + :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 6px; + } + + ::selection { + background: var(--accent-soft); + color: var(--text); + } + + a { + color: inherit; + text-decoration: none; + } +} diff --git a/src/app/icon.svg b/src/app/icon.svg new file mode 100644 index 0000000..b5f29a9 --- /dev/null +++ b/src/app/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fb9fcde..7dae5f8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,27 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import type { ReactNode } from "react"; + +// Schriften selbst gehostet (#002): keine CDNs, nur die Gewichte, die das Design nutzt, +// und nur der Latin-Ausschnitt (enthält die deutschen Umlaute und ß). +import "@fontsource/space-grotesk/latin-500.css"; +import "@fontsource/space-grotesk/latin-600.css"; +import "@fontsource/space-grotesk/latin-700.css"; +import "@fontsource/ibm-plex-sans/latin-400.css"; +import "@fontsource/ibm-plex-sans/latin-500.css"; +import "@fontsource/ibm-plex-sans/latin-600.css"; +import "@fontsource/jetbrains-mono/latin-400.css"; +import "@fontsource/jetbrains-mono/latin-500.css"; import "./globals.css"; export const metadata: Metadata = { title: { default: "SSHKeyVault", template: "%s · SSHKeyVault" }, description: "Zentrale Verwaltung von SSH-Schlüsseln", + applicationName: "SSHKeyVault", +}; + +export const viewport: Viewport = { + themeColor: "#0D1013", + colorScheme: "dark", }; export default function RootLayout({ children }: { children: ReactNode }) { diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..7e6ee11 --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,20 @@ +import type { MetadataRoute } from "next"; + +/** Web-App-Manifest; die Icons erzeugt scripts/icons.mjs (#002). */ +export default function manifest(): MetadataRoute.Manifest { + return { + name: "SSHKeyVault", + short_name: "sshkv", + description: "Zentrale Verwaltung von SSH-Schlüsseln", + lang: "de", + start_url: "/", + display: "standalone", + background_color: "#0D1013", + theme_color: "#0D1013", + icons: [ + { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, + { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, + { src: "/brand/favicon.svg", sizes: "any", type: "image/svg+xml" }, + ], + }; +} diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index 9b089d7..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Startseite() { - return
SSHKeyVault
; -} diff --git a/src/beispiel/schluessel.ts b/src/beispiel/schluessel.ts new file mode 100644 index 0000000..679edf0 --- /dev/null +++ b/src/beispiel/schluessel.ts @@ -0,0 +1,75 @@ +/** + * BEISPIELDATEN für den Prototyp des Schlüsseldetails (#002) – kein echter Schlüssel. + * Ersetzt durch das Lesemodell aus #012/#016/#017. + */ +import type { HostStatus } from "@/components/ui/Statuspunkt"; +import type { DiffZeile } from "@/components/ui/DiffBox"; + +export const beispielSchluessel = { + zweck: "Backup srv-backup01 → srv-pve01", + typ: "ed25519", + kategorie: "Dienst", + vorlage: "Backup-Zugang", + ablauf: "2026-10-30T10:00:00Z", + fingerprint: "SHA256:q3vN8xP0b4Tz7KfL2mWcR9yHdE1sJ6uAoGi5VnBQe0k", + erstellt: "2026-04-30T08:12:00Z", + erstelltVon: "Florian Wilde", +}; + +export type Ausrollung = { + ziel: string; + pfad: string; + zusatz?: string; + status: HostStatus; + statusText?: string; +}; + +export const privatAusgerollt: Ausrollung[] = [ + { + ziel: "backup@srv-backup01", + pfad: "~/.ssh/id_ed25519_pve01", + zusatz: "config: Host srv-pve01", + status: "synchron", + }, +]; + +export const publicAusgerollt: Ausrollung[] = [ + { + ziel: "backup@srv-pve01", + pfad: "~/.ssh/authorized_keys", + zusatz: 'from="10.20.0.15",no-pty,no-agent-forwarding', + status: "synchron", + }, + { + ziel: "backup@srv-pve02", + pfad: "~/.ssh/authorized_keys", + zusatz: 'from="10.20.0.15",no-pty', + status: "drift", + statusText: "Optionen geändert", + }, +]; + +export const dryRunDatei = "backup@srv-pve02:~/.ssh/authorized_keys"; + +export const dryRunDiff: DiffZeile[] = [ + { art: "kontext", text: "# --- sshkv verwaltet: Anfang ---" }, + { + art: "kontext", + text: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMvq2Lr7… monitoring@zabbix", + }, + { + art: "entfernt", + text: 'from="10.20.0.15",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01', + }, + { + art: "hinzu", + text: 'from="10.20.0.15",no-pty,no-agent-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01', + }, + { art: "kontext", text: "# --- sshkv verwaltet: Ende ---" }, +]; + +export const zusatzaktionen = [ + { id: "backup", text: "Backup der Datei anlegen (authorized_keys.sshkv-bak)", an: true }, + { id: "config", text: "~/.ssh/config-Eintrag auf srv-backup01 anlegen", an: true }, + { id: "knownhosts", text: "known_hosts auf srv-backup01 aktualisieren", an: false }, +]; diff --git a/src/beispiel/uebersicht.ts b/src/beispiel/uebersicht.ts new file mode 100644 index 0000000..d0f9bc0 --- /dev/null +++ b/src/beispiel/uebersicht.ts @@ -0,0 +1,89 @@ +/** + * BEISPIELDATEN für den klickbaren Prototyp (#002) – keine echten Hosts oder Schlüssel. + * Die echten Lesemodelle kommen in #021 (Abgleich) und #026 (Übersicht); dann entfällt + * diese Datei. Alle Zeiten beziehen sich auf BEISPIEL_JETZT, damit der statisch gebaute + * Prototyp nicht mit jedem Build andere Relativangaben zeigt. + */ +import type { HostStatus } from "@/components/ui/Statuspunkt"; + +export const BEISPIEL_JETZT = "2026-10-12T07:00:00Z"; // 09:00 Berliner Zeit + +export const letzterAbgleich = "2026-10-12T06:42:00Z"; + +export const kennzahlen = { + hosts: { wert: 42, hinweis: "38 synchron · 1 nicht erreichbar" }, + schluessel: { wert: 117, hinweis: "davon 9 mit Private Key" }, + abweichungen: { wert: 3, hinweis: "auf 3 Hosts" }, + ablaufend: { wert: 5, hinweis: "in den nächsten 30 Tagen" }, +}; + +export type Abweichung = { + host: string; + benutzer: string; + status: HostStatus; + befund: string; +}; + +export const abweichungen: Abweichung[] = [ + { host: "srv-web03.intern", benutzer: "root", status: "drift", befund: "1 fremder Schlüssel" }, + { host: "srv-db01.intern", benutzer: "deploy", status: "drift", befund: "Schlüssel fehlt" }, + { host: "fw-edge01.intern", benutzer: "sshkv", status: "drift", befund: "Optionen geändert" }, + { host: "srv-legacy02.intern", benutzer: "root", status: "unerreichbar", befund: "nicht erreichbar" }, +]; + +export type AblaufEintrag = { zweck: string; person: string; typ: string; ablauf: string }; + +export const baldAblaufend: AblaufEintrag[] = [ + { zweck: "Laptop Florian", person: "Florian Wilde", typ: "ed25519", ablauf: "2026-10-16T10:00:00Z" }, + { zweck: "CI-Deployment Shop", person: "Dienst", typ: "ed25519", ablauf: "2026-10-21T10:00:00Z" }, + { + zweck: "Backup srv-backup01 → srv-pve01", + person: "Dienst", + typ: "ed25519", + ablauf: "2026-10-30T10:00:00Z", + }, + { zweck: "Notebook Sandra", person: "Sandra Keller", typ: "rsa-4096", ablauf: "2026-11-04T10:00:00Z" }, + { zweck: "Monitoring Zabbix", person: "Dienst", typ: "ed25519", ablauf: "2026-11-09T10:00:00Z" }, +]; + +export type AktionsArt = "ausgerollt" | "widerrufen" | "erstellt" | "abgleich" | "rotiert"; + +export type LetzteAktion = { + zeit: string; + art: AktionsArt; + beschreibung: string; + benutzer: string; +}; + +export const letzteAktionen: LetzteAktion[] = [ + { + zeit: "2026-10-12T06:42:00Z", + art: "abgleich", + beschreibung: "Abgleich aller Hosts: 3 Abweichungen", + benutzer: "System", + }, + { + zeit: "2026-10-12T06:15:00Z", + art: "ausgerollt", + beschreibung: "Public Key „Backup srv-backup01 → srv-pve01“ auf backup@srv-pve01", + benutzer: "Florian Wilde", + }, + { + zeit: "2026-10-11T15:30:00Z", + art: "widerrufen", + beschreibung: "Schlüssel „Laptop alt Thomas“ von 6 Hosts entfernt", + benutzer: "Florian Wilde", + }, + { + zeit: "2026-10-11T12:04:00Z", + art: "rotiert", + beschreibung: "CI-Deployment Shop durch neuen ed25519-Schlüssel ersetzt", + benutzer: "Sandra Keller", + }, + { + zeit: "2026-10-11T09:20:00Z", + art: "erstellt", + beschreibung: "Schlüssel „Monitoring Zabbix“ aus Vorlage Deployment-Zugang", + benutzer: "Sandra Keller", + }, +]; 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", +}; diff --git a/src/components/marke/Bildmarke.tsx b/src/components/marke/Bildmarke.tsx new file mode 100644 index 0000000..e553e18 --- /dev/null +++ b/src/components/marke/Bildmarke.tsx @@ -0,0 +1,49 @@ +/** + * Bildmarke (Tresortür mit Schlüsselloch) als Inline-SVG (#002). + * + * Inline statt , damit die Marke ohne zusätzliche Anfrage + * sofort steht und die Farben aus den CSS-Tokens kommen. Geometrie identisch mit + * public/brand/logo.svg – Änderungen bitte an beiden Stellen und `pnpm icons` laufen lassen. + */ +export function Bildmarke({ + groesse = 34, + titel, + className, +}: { + /** Kantenlänge in px. */ + groesse?: number; + /** Zugänglicher Name; ohne Titel gilt die Marke als Schmuck (neben der Wortmarke). */ + titel?: string; + className?: string; +}) { + const linie = "var(--text)"; + const messing = "var(--accent)"; + return ( + + + + + + + + + + + ); +} diff --git a/src/components/marke/Wortmarke.tsx b/src/components/marke/Wortmarke.tsx new file mode 100644 index 0000000..ab247eb --- /dev/null +++ b/src/components/marke/Wortmarke.tsx @@ -0,0 +1,38 @@ +import { clsx } from "clsx"; +import { Bildmarke } from "./Bildmarke"; + +/** „SSHKey“ in Textfarbe, „Vault“ in Messing – Space Grotesk 700 (#002). */ +export function Wortmarke({ groesse = 22, className }: { groesse?: number; className?: string }) { + return ( + + SSHKey + Vault + + ); +} + +/** Kurzname immer kleingeschrieben in JetBrains Mono. */ +export function Kurzname({ className }: { className?: string }) { + return sshkv; +} + +/** Bildmarke + Wortmarke nebeneinander, Standardgrößen wie in der Seitenleiste (34/22 px). */ +export function Marke({ + bildGroesse = 34, + textGroesse = 22, + className, +}: { + bildGroesse?: number; + textGroesse?: number; + className?: string; +}) { + return ( + + + + + ); +} diff --git a/src/components/marke/index.ts b/src/components/marke/index.ts new file mode 100644 index 0000000..8036dda --- /dev/null +++ b/src/components/marke/index.ts @@ -0,0 +1,2 @@ +export { Bildmarke } from "./Bildmarke"; +export { Kurzname, Marke, Wortmarke } from "./Wortmarke"; diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 0000000..0d5b078 --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -0,0 +1,38 @@ +import { clsx } from "clsx"; +import type { ReactNode } from "react"; + +export type BadgeVariante = "neutral" | "messing" | "ok" | "gefahr"; + +const VARIANTEN: Record = { + neutral: "bg-raised text-text border-border-strong", + // Messing auf --accent-soft: Ablaufhinweise und Vorlagen-Kennzeichnung. + messing: "bg-accent-soft text-accent border-[#5a4519]", + ok: "bg-[#12261f] text-ok border-[#1f4a3d]", + gefahr: "bg-danger-soft text-danger border-danger-border", +}; + +/** Pillenförmiges Etikett; `mono` für technische Werte wie Schlüsseltypen (#002). */ +export function Badge({ + variante = "neutral", + mono = false, + children, + className, +}: { + variante?: BadgeVariante; + mono?: boolean; + children: ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/src/components/ui/Breadcrumb.tsx b/src/components/ui/Breadcrumb.tsx new file mode 100644 index 0000000..05dba65 --- /dev/null +++ b/src/components/ui/Breadcrumb.tsx @@ -0,0 +1,38 @@ +import { ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { Fragment } from "react"; + +export type BreadcrumbEintrag = { titel: string; href?: string }; + +/** Brotkrumen-Navigation; der letzte Eintrag ist die aktuelle Seite (#002). */ +export function Breadcrumb({ pfad }: { pfad: BreadcrumbEintrag[] }) { + return ( + + ); +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..9d76fff --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,64 @@ +import { clsx } from "clsx"; +import Link from "next/link"; +import type { ComponentProps, ReactNode } from "react"; + +export type ButtonVariante = "primaer" | "sekundaer" | "gefahr"; + +const VARIANTEN: Record = { + primaer: "bg-accent text-bg font-semibold border-accent hover:bg-[#f5c25c]", + sekundaer: "bg-surface text-text font-medium border-border-strong hover:bg-raised", + gefahr: "bg-transparent text-danger font-medium border-danger-border hover:bg-danger-soft", +}; + +/** Gemeinsame Klassen – auch für Elemente, die wie ein Button aussehen sollen. */ +export function buttonKlassen(variante: ButtonVariante = "sekundaer", klein = false, className?: string) { + return clsx( + // 44 px Mindesthöhe als Klickfläche (WCAG 2.5.5), auch in der kleinen Variante. + "inline-flex min-h-11 items-center justify-center gap-2 rounded-box border px-4 text-[14px] leading-tight whitespace-nowrap transition-colors", + "disabled:cursor-not-allowed disabled:opacity-50", + klein && "px-3 text-[13px]", + VARIANTEN[variante], + className, + ); +} + +type GemeinsameProps = { + variante?: ButtonVariante; + /** Lucide-Icon o. Ä. vor dem Text. */ + icon?: ReactNode; + klein?: boolean; + className?: string; + children: ReactNode; +}; + +type AlsButton = GemeinsameProps & Omit, keyof GemeinsameProps> & { href?: never }; +type AlsLink = GemeinsameProps & Omit, keyof GemeinsameProps> & { href: string }; + +/** Button in den Varianten primär/sekundär/Gefahr; mit `href` wird daraus ein Link. */ +export function Button(props: AlsButton | AlsLink) { + const { variante = "sekundaer", icon, klein = false, className, children, ...rest } = props; + const klassen = buttonKlassen(variante, klein, className); + const inhalt = ( + <> + {icon && ( + + {icon} + + )} + {children} + + ); + if ("href" in rest && rest.href !== undefined) { + return ( + )} className={klassen}> + {inhalt} + + ); + } + const buttonRest = rest as Omit; + return ( + + ); +} diff --git a/src/components/ui/DiffBox.tsx b/src/components/ui/DiffBox.tsx new file mode 100644 index 0000000..9d966c8 --- /dev/null +++ b/src/components/ui/DiffBox.tsx @@ -0,0 +1,55 @@ +import { clsx } from "clsx"; +import type { ReactNode } from "react"; + +export type DiffZeile = { art: "hinzu" | "entfernt" | "kontext"; text: string }; + +const ZEICHEN = { hinzu: "+", entfernt: "−", kontext: " " } as const; +const LESBAR = { hinzu: "hinzugefügt", entfernt: "entfernt", kontext: "unverändert" } as const; + +/** + * Diff-Vorschau (Dry-Run) für authorized_keys, ~/.ssh/config & Co. (#002). + * Entfernte Zeilen Koralle auf --danger-soft, hinzugefügte in --ok. Lange Schlüsselzeilen + * brechen um statt die Seite zu verbreitern – ein Public Key ist sonst 400+ Zeichen breit. + */ +export function DiffBox({ + zeilen, + datei, + fuss, + className, +}: { + zeilen: DiffZeile[]; + /** Pfad der Datei als Kopfzeile, z. B. „backup@srv-pve01:~/.ssh/authorized_keys“. */ + datei?: string; + fuss?: ReactNode; + className?: string; +}) { + return ( +
+ {datei && ( +
+ {datei} +
+ )} +
    + {zeilen.map((z, i) => ( +
  1. + + {ZEICHEN[z.art]} + + {LESBAR[z.art]}: + {z.text || " "} +
  2. + ))} +
+ {fuss &&
{fuss}
} +
+ ); +} diff --git a/src/components/ui/Karte.tsx b/src/components/ui/Karte.tsx new file mode 100644 index 0000000..c1b6bfd --- /dev/null +++ b/src/components/ui/Karte.tsx @@ -0,0 +1,76 @@ +import { clsx } from "clsx"; +import type { ReactNode } from "react"; + +/** + * Karte: --surface, 1 px Rahmen, 16 px Radius, 22–24 px Innenabstand (#002). + * Mit `titel` entsteht ein Kopf mit optionalen Aktionen rechts. + */ +export function Karte({ + titel, + untertitel, + aktionen, + children, + className, + innen = "normal", + as: Element = "section", +}: { + titel?: ReactNode; + untertitel?: ReactNode; + aktionen?: ReactNode; + children?: ReactNode; + className?: string; + /** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */ + innen?: "normal" | "ohne"; + as?: "section" | "div" | "article"; +}) { + return ( + + {(titel || aktionen) && ( + + )} + {children} + + ); +} + +function KartenKopf({ + titel, + untertitel, + aktionen, + rand, +}: { + titel?: ReactNode; + untertitel?: ReactNode; + aktionen?: ReactNode; + rand: boolean; +}) { + return ( +
+
+ {titel &&

{titel}

} + {untertitel &&

{untertitel}

} +
+ {aktionen &&
{aktionen}
} +
+ ); +} + +/** Innere Box (--sidebar, 12 px Radius) – für Einträge innerhalb einer Karte. */ +export function InnereBox({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/ui/Kennzahl.tsx b/src/components/ui/Kennzahl.tsx new file mode 100644 index 0000000..3be6295 --- /dev/null +++ b/src/components/ui/Kennzahl.tsx @@ -0,0 +1,51 @@ +import { clsx } from "clsx"; +import Link from "next/link"; +import type { ReactNode } from "react"; + +export type KennzahlVariante = "neutral" | "gefahr" | "messing"; + +const WERTFARBE: Record = { + neutral: "text-text", + gefahr: "text-danger", + messing: "text-accent", +}; + +/** Kennzahl-Kachel: große Zahl in Space Grotesk, Bezeichnung darüber, Hinweis darunter (#002). */ +export function Kennzahl({ + titel, + wert, + hinweis, + variante = "neutral", + href, +}: { + titel: string; + wert: ReactNode; + hinweis?: ReactNode; + variante?: KennzahlVariante; + /** Macht die ganze Kachel zum Link (z. B. auf die gefilterte Liste). */ + href?: string; +}) { + const inhalt = ( + <> +

{titel}

+

+ {wert} +

+ {hinweis &&

{hinweis}

} + + ); + const klassen = "border-border bg-surface block min-w-0 rounded-karte border p-5 sm:p-6"; + if (href) { + return ( + + {inhalt} + + ); + } + return
{inhalt}
; +} diff --git a/src/components/ui/Leerzustand.tsx b/src/components/ui/Leerzustand.tsx new file mode 100644 index 0000000..19bd242 --- /dev/null +++ b/src/components/ui/Leerzustand.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; + +/** Hinweis für leere Listen und noch nicht gebaute Seiten (#002). */ +export function Leerzustand({ + titel, + text, + icon, + aktion, +}: { + titel: string; + text?: ReactNode; + icon?: ReactNode; + aktion?: ReactNode; +}) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +

{titel}

+ {text &&
{text}
} + {aktion &&
{aktion}
} +
+ ); +} diff --git a/src/components/ui/MonoKopieren.tsx b/src/components/ui/MonoKopieren.tsx new file mode 100644 index 0000000..5f00777 --- /dev/null +++ b/src/components/ui/MonoKopieren.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { clsx } from "clsx"; +import { Check, Copy } from "lucide-react"; +import { useRef, useState } from "react"; + +/** + * Technischer Wert in Monospace mit Kopier-Button (#002). Client-Komponente, weil die + * Zwischenablage nur im Browser erreichbar ist. Nur für öffentliche Werte gedacht + * (Fingerprints, Public Keys, Befehle) – Private Keys gehören nie in Client-Props. + */ +export function MonoKopieren({ + wert, + beschriftung = "Wert", + umbrechen = false, + className, +}: { + wert: string; + /** Wofür der Button kopiert, für Screenreader: „Fingerprint kopieren“. */ + beschriftung?: string; + /** Lange Werte umbrechen statt abschneiden. */ + umbrechen?: boolean; + className?: string; +}) { + const [kopiert, setKopiert] = useState(false); + const zeitgeber = useRef | null>(null); + + async function kopieren() { + try { + await navigator.clipboard.writeText(wert); + setKopiert(true); + if (zeitgeber.current) clearTimeout(zeitgeber.current); + zeitgeber.current = setTimeout(() => setKopiert(false), 1800); + } catch { + // Ohne sichere Verbindung (http im LAN) gibt es keine Clipboard-API; der Wert + // bleibt markierbar, daher kein Fehlerdialog. + } + } + + return ( + + + {wert} + + + + {kopiert ? `${beschriftung} kopiert` : ""} + + + ); +} diff --git a/src/components/ui/Seitenkopf.tsx b/src/components/ui/Seitenkopf.tsx new file mode 100644 index 0000000..374d536 --- /dev/null +++ b/src/components/ui/Seitenkopf.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +/** Kopf jeder Seite: optional Brotkrumen, Titel, Badges, Untertitel und Aktionen rechts (#002). */ +export function Seitenkopf({ + titel, + untertitel, + aktionen, + oben, + badges, +}: { + titel: ReactNode; + untertitel?: ReactNode; + aktionen?: ReactNode; + /** Über dem Titel, typischerweise eine Breadcrumb. */ + oben?: ReactNode; + /** Unter dem Titel, z. B. Typ/Kategorie/Ablauf. */ + badges?: ReactNode; +}) { + return ( +
+
+ {oben &&
{oben}
} +

+ {titel} +

+ {badges &&
{badges}
} + {untertitel &&
{untertitel}
} +
+ {aktionen &&
{aktionen}
} +
+ ); +} diff --git a/src/components/ui/Statuspunkt.tsx b/src/components/ui/Statuspunkt.tsx new file mode 100644 index 0000000..b55ea74 --- /dev/null +++ b/src/components/ui/Statuspunkt.tsx @@ -0,0 +1,41 @@ +import { clsx } from "clsx"; + +export type HostStatus = "synchron" | "drift" | "unerreichbar" | "unbekannt"; + +/* + * Status nie nur über Farbe (#002): Punktform und Text tragen die Information mit, + * damit sie auch bei Farbfehlsichtigkeit und in Graustufen-Ausdrucken lesbar bleibt. + */ +const DARSTELLUNG: Record = { + synchron: { text: "synchron", punkt: "bg-ok", farbe: "text-ok" }, + drift: { text: "Drift", punkt: "bg-danger", farbe: "text-danger" }, + unerreichbar: { + text: "nicht erreichbar", + punkt: "border-[1.5px] border-muted bg-transparent", + farbe: "text-muted", + }, + unbekannt: { + text: "unbekannt", + punkt: "border-[1.5px] border-dashed border-muted bg-transparent", + farbe: "text-muted", + }, +}; + +export function Statuspunkt({ + status, + text, + className, +}: { + status: HostStatus; + /** Abweichender Text (z. B. „2 fremde Schlüssel“); ohne ihn der Standardtext des Status. */ + text?: string; + className?: string; +}) { + const d = DARSTELLUNG[status]; + return ( + + + {text ?? d.text} + + ); +} diff --git a/src/components/ui/Tabelle.tsx b/src/components/ui/Tabelle.tsx new file mode 100644 index 0000000..306b25f --- /dev/null +++ b/src/components/ui/Tabelle.tsx @@ -0,0 +1,85 @@ +import { clsx } from "clsx"; +import type { ReactNode } from "react"; + +export type Spalte = { + titel: ReactNode; + /** Rechtsbündig für Zahlen und Aktionen. */ + rechts?: boolean; + /** Nur für Screenreader (z. B. Aktionsspalte ohne sichtbare Überschrift). */ + versteckt?: boolean; + className?: string; +}; + +/** + * Tabelle mit eigenem horizontalem Scrollbereich (#002): Breite Inhalte schieben nie die + * ganze Seite, auf 390 px scrollt nur die Tabelle. Kopf mit --border-strong-Linie. + */ +export function Tabelle({ + spalten, + children, + beschriftung, + className, +}: { + spalten: Spalte[]; + children: ReactNode; + /** Zugänglicher Name der Tabelle (caption, visuell versteckt). */ + beschriftung?: string; + className?: string; +}) { + return ( + // relative: sonst entkommen absolut positionierte Kinder (sr-only-Texte) dem + // Scrollbereich und verbreitern die ganze Seite. +
+ + {beschriftung && } + + + {spalten.map((s, i) => ( + + ))} + + + {children} +
{beschriftung}
+ {s.versteckt ? {s.titel} : s.titel} +
+
+ ); +} + +export function TabellenZeile({ children, className }: { children: ReactNode; className?: string }) { + return {children}; +} + +export function TabellenZelle({ + children, + rechts, + mono, + className, +}: { + children: ReactNode; + rechts?: boolean; + mono?: boolean; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..d6c20a9 --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1,11 @@ +export { Badge, type BadgeVariante } from "./Badge"; +export { Breadcrumb, type BreadcrumbEintrag } from "./Breadcrumb"; +export { Button, buttonKlassen, type ButtonVariante } from "./Button"; +export { DiffBox, type DiffZeile } from "./DiffBox"; +export { InnereBox, Karte } from "./Karte"; +export { Kennzahl, type KennzahlVariante } from "./Kennzahl"; +export { Leerzustand } from "./Leerzustand"; +export { MonoKopieren } from "./MonoKopieren"; +export { Seitenkopf } from "./Seitenkopf"; +export { Statuspunkt, type HostStatus } from "./Statuspunkt"; +export { Tabelle, TabellenZeile, TabellenZelle, type Spalte } from "./Tabelle"; diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..d946fc0 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,89 @@ +/** + * Datums- und Zeitformate der Oberfläche (#002): TT.MM.JJJJ, 24-h-Zeit, deutsch. + * + * Die Zeitzone ist fest Europe/Berlin und nicht die des Prozesses: Server (UTC im + * Container) und Browser sollen dieselbe Zeit anzeigen, sonst springen Zeitstempel + * zwischen Server- und Client-Rendering um Stunden. + */ + +export const ZEITZONE = "Europe/Berlin"; + +type Eingabe = Date | string | number; + +const DATUM = new Intl.DateTimeFormat("de-DE", { + timeZone: ZEITZONE, + day: "2-digit", + month: "2-digit", + year: "numeric", +}); + +const UHRZEIT = new Intl.DateTimeFormat("de-DE", { + timeZone: ZEITZONE, + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", +}); + +function alsDatum(wert: Eingabe): Date { + const d = wert instanceof Date ? wert : new Date(wert); + if (Number.isNaN(d.getTime())) throw new RangeError(`Ungültiges Datum: ${String(wert)}`); + return d; +} + +/** „12.10.2026“ */ +export function datum(wert: Eingabe): string { + return DATUM.format(alsDatum(wert)); +} + +/** „08:42“ */ +export function uhrzeit(wert: Eingabe): string { + return UHRZEIT.format(alsDatum(wert)); +} + +/** „12.10.2026, 08:42“ */ +export function datumZeit(wert: Eingabe): string { + const d = alsDatum(wert); + return `${DATUM.format(d)}, ${UHRZEIT.format(d)}`; +} + +const MINUTE = 60_000; +const STUNDE = 60 * MINUTE; +const TAG = 24 * STUNDE; + +/** + * Grobe Relativangabe für Listen: „gerade eben“, „vor 5 Min.“, „vor 3 Std.“, + * „vor 2 Tagen“, „in 12 Tagen“. Ab 30 Tagen Abstand wird das Datum gezeigt, weil + * „vor 47 Tagen“ schlechter einzuordnen ist als ein Datum. + * + * `jetzt` ist ein Parameter, damit Tests und statisch gerenderte Seiten einen + * festen Bezugspunkt haben. + */ +export function relativ(wert: Eingabe, jetzt: Eingabe = new Date()): string { + const abstand = alsDatum(wert).getTime() - alsDatum(jetzt).getTime(); + const betrag = Math.abs(abstand); + const zukunft = abstand > 0; + + if (betrag < MINUTE) return "gerade eben"; + if (betrag >= 30 * TAG) return `am ${datum(wert)}`; + + let text: string; + if (betrag < STUNDE) { + text = `${Math.floor(betrag / MINUTE)} Min.`; + } else if (betrag < TAG) { + text = `${Math.floor(betrag / STUNDE)} Std.`; + } else { + const tage = Math.floor(betrag / TAG); + // „vor“ und „in“ verlangen beide den Dativ: „vor 1 Tag“, „in 2 Tagen“. + text = tage === 1 ? "1 Tag" : `${tage} Tagen`; + } + return zukunft ? `in ${text}` : `vor ${text}`; +} + +/** Ganze Kalendertage bis `wert` (negativ, wenn vorbei) – Grundlage für Ablaufhinweise. */ +export function tageBis(wert: Eingabe, jetzt: Eingabe = new Date()): number { + const tagesnummer = (d: Date) => { + const [t, m, j] = DATUM.format(d).split(".").map(Number); + return Date.UTC(j ?? 0, (m ?? 1) - 1, t ?? 1) / TAG; + }; + return Math.round(tagesnummer(alsDatum(wert)) - tagesnummer(alsDatum(jetzt))); +} diff --git a/tests/format.test.ts b/tests/format.test.ts new file mode 100644 index 0000000..88a90d5 --- /dev/null +++ b/tests/format.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { datum, datumZeit, relativ, tageBis, uhrzeit, ZEITZONE } from "@/lib/format"; + +// Alle Erwartungen gelten für Europe/Berlin, unabhängig von der Zeitzone des Testprozesses. +describe("Formate (#002)", () => { + it("nutzt fest Europe/Berlin", () => { + expect(ZEITZONE).toBe("Europe/Berlin"); + }); + + describe("datum()", () => { + it("schreibt TT.MM.JJJJ mit führenden Nullen", () => { + expect(datum("2026-03-05T10:00:00Z")).toBe("05.03.2026"); + }); + it("rechnet in Berliner Zeit – 23:30 UTC ist dort schon der nächste Tag", () => { + expect(datum("2026-10-12T23:30:00Z")).toBe("13.10.2026"); + }); + it("nimmt Date, Zeitstempel und ISO-Text", () => { + const d = new Date("2026-01-01T12:00:00Z"); + expect(datum(d)).toBe("01.01.2026"); + expect(datum(d.getTime())).toBe("01.01.2026"); + }); + it("lehnt ungültige Eingaben ab", () => { + expect(() => datum("kein Datum")).toThrow(RangeError); + }); + }); + + describe("uhrzeit() und datumZeit()", () => { + it("zeigt 24-h-Zeit in Sommerzeit (UTC+2)", () => { + expect(uhrzeit("2026-07-01T13:05:00Z")).toBe("15:05"); + }); + it("zeigt 24-h-Zeit in Winterzeit (UTC+1)", () => { + expect(uhrzeit("2026-12-01T22:59:00Z")).toBe("23:59"); + }); + it("Mitternacht ist 00:00, nicht 24:00", () => { + expect(uhrzeit("2026-12-01T23:00:00Z")).toBe("00:00"); + }); + it("verbindet Datum und Zeit", () => { + expect(datumZeit("2026-10-12T06:42:00Z")).toBe("12.10.2026, 08:42"); + }); + it("berücksichtigt die Zeitumstellung am 25.10.2026", () => { + expect(datumZeit("2026-10-25T00:30:00Z")).toBe("25.10.2026, 02:30"); + expect(datumZeit("2026-10-25T01:30:00Z")).toBe("25.10.2026, 02:30"); + }); + }); + + describe("relativ()", () => { + const jetzt = new Date("2026-10-12T10:00:00Z"); + const vor = (ms: number) => new Date(jetzt.getTime() - ms); + const MIN = 60_000; + const STD = 60 * MIN; + const TAG = 24 * STD; + + it("unter einer Minute: gerade eben", () => { + expect(relativ(vor(30_000), jetzt)).toBe("gerade eben"); + expect(relativ(jetzt, jetzt)).toBe("gerade eben"); + }); + it("Minuten", () => { + expect(relativ(vor(5 * MIN), jetzt)).toBe("vor 5 Min."); + expect(relativ(vor(59 * MIN), jetzt)).toBe("vor 59 Min."); + }); + it("Stunden", () => { + expect(relativ(vor(3 * STD), jetzt)).toBe("vor 3 Std."); + expect(relativ(vor(23 * STD + 59 * MIN), jetzt)).toBe("vor 23 Std."); + }); + it("Tage mit Einzahl und Mehrzahl", () => { + expect(relativ(vor(TAG), jetzt)).toBe("vor 1 Tag"); + expect(relativ(vor(4 * TAG), jetzt)).toBe("vor 4 Tagen"); + }); + it("Zukunft", () => { + expect(relativ(vor(-12 * TAG), jetzt)).toBe("in 12 Tagen"); + expect(relativ(vor(-2 * STD), jetzt)).toBe("in 2 Std."); + }); + it("ab 30 Tagen das Datum", () => { + expect(relativ(vor(40 * TAG), jetzt)).toBe("am 02.09.2026"); + }); + it("nimmt ohne Bezugspunkt die aktuelle Zeit", () => { + expect(relativ(new Date())).toBe("gerade eben"); + }); + }); + + describe("tageBis()", () => { + const jetzt = "2026-10-12T21:00:00Z"; // in Berlin schon 23:00 am 12.10. + it("zählt Kalendertage in Berliner Zeit", () => { + expect(tageBis("2026-10-12T22:30:00Z", jetzt)).toBe(1); // 00:30 am 13.10. + expect(tageBis("2026-11-11T12:00:00Z", jetzt)).toBe(30); + }); + it("ist negativ für Vergangenes", () => { + expect(tageBis("2026-10-10T12:00:00Z", jetzt)).toBe(-2); + }); + }); +});