Merge branch 'phase/002-design-layout'
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 126 KiB |
@@ -1,25 +1,51 @@
|
|||||||
# #002 Design-System und Layout
|
# #002 Design-System und Layout
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** ui
|
**Labels:** ui
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0,3 h (09:07–09:22)
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Design laut Vorgabe (Tresor, Graphit + Messing): Tokens, Schriften, Logo, Favicon/PWA-Icons, Grundlayout mit Seitenleiste.
|
Design laut Vorgabe (Tresor, Graphit + Messing): Tokens, Schriften, Logo, Favicon/PWA-Icons, Grundlayout mit Seitenleiste.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Design-Tokens als CSS-Variablen + Tailwind-Theme
|
- [x] Design-Tokens als CSS-Variablen + Tailwind-Theme
|
||||||
- [ ] Space Grotesk, IBM Plex Sans, JetBrains Mono selbst gehostet (`@fontsource`)
|
- [x] 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
|
- [x] `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
|
- [x] 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] Grundkomponenten: Karte, Button (primär/sekundär/Gefahr), Badge, Statuspunkt, Tabelle, Diff-Box, Kennzahl-Kachel
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Kontrast ≥ 4,5:1, Fokusrahmen in Messing
|
- [x] Kontrast ≥ 4,5:1, Fokusrahmen in Messing
|
||||||
- [ ] Kein horizontales Scrollen bei 390 px
|
- [x] Kein horizontales Scrollen bei 390 px
|
||||||
- [ ] Klickflächen ≥ 44 px
|
- [x] Klickflächen ≥ 44 px
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect x="2" y="2" width="28" height="28" rx="7" fill="none" stroke="#E8ECEF" stroke-width="3"/>
|
||||||
|
<circle cx="16" cy="13.5" r="4.6" fill="#F0B43C"/>
|
||||||
|
<path d="M13.8 15.5 L12 23.5 H20 L18.2 15.5 Z" fill="#F0B43C"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 285 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="SSHKeyVault">
|
||||||
|
<rect x="4" y="4" width="56" height="56" rx="14" fill="none" stroke="#14181C" stroke-width="3.5"/>
|
||||||
|
<circle cx="32" cy="32" r="19" fill="none" stroke="#14181C" stroke-opacity="0.25" stroke-width="2" stroke-dasharray="3 3.2"/>
|
||||||
|
<circle cx="13.5" cy="13.5" r="2.2" fill="#14181C"/>
|
||||||
|
<circle cx="50.5" cy="13.5" r="2.2" fill="#14181C"/>
|
||||||
|
<circle cx="13.5" cy="50.5" r="2.2" fill="#14181C"/>
|
||||||
|
<circle cx="50.5" cy="50.5" r="2.2" fill="#14181C"/>
|
||||||
|
<circle cx="32" cy="27" r="7.5" fill="#B9780F"/>
|
||||||
|
<path d="M28.6 30.5 L25.5 45 H38.5 L35.4 30.5 Z" fill="#B9780F"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 672 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="SSHKeyVault">
|
||||||
|
<rect x="2.25" y="2.25" width="59.5" height="59.5" rx="15.75" fill="#F0B43C"/>
|
||||||
|
<rect x="4" y="4" width="56" height="56" rx="14" fill="none" stroke="#0D1013" stroke-width="3.5"/>
|
||||||
|
<circle cx="32" cy="32" r="19" fill="none" stroke="#0D1013" stroke-opacity="0.28" stroke-width="2" stroke-dasharray="3 3.2"/>
|
||||||
|
<circle cx="13.5" cy="13.5" r="2.2" fill="#0D1013"/>
|
||||||
|
<circle cx="50.5" cy="13.5" r="2.2" fill="#0D1013"/>
|
||||||
|
<circle cx="13.5" cy="50.5" r="2.2" fill="#0D1013"/>
|
||||||
|
<circle cx="50.5" cy="50.5" r="2.2" fill="#0D1013"/>
|
||||||
|
<circle cx="32" cy="27" r="7.5" fill="#0D1013"/>
|
||||||
|
<path d="M28.6 30.5 L25.5 45 H38.5 L35.4 30.5 Z" fill="#0D1013"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 753 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="SSHKeyVault">
|
||||||
|
<rect x="4" y="4" width="56" height="56" rx="14" fill="none" stroke="#E8ECEF" stroke-width="3.5"/>
|
||||||
|
<circle cx="32" cy="32" r="19" fill="none" stroke="#E8ECEF" stroke-opacity="0.28" stroke-width="2" stroke-dasharray="3 3.2"/>
|
||||||
|
<circle cx="13.5" cy="13.5" r="2.2" fill="#E8ECEF"/>
|
||||||
|
<circle cx="50.5" cy="13.5" r="2.2" fill="#E8ECEF"/>
|
||||||
|
<circle cx="13.5" cy="50.5" r="2.2" fill="#E8ECEF"/>
|
||||||
|
<circle cx="50.5" cy="50.5" r="2.2" fill="#E8ECEF"/>
|
||||||
|
<circle cx="32" cy="27" r="7.5" fill="#F0B43C"/>
|
||||||
|
<path d="M28.6 30.5 L25.5 45 H38.5 L35.4 30.5 Z" fill="#F0B43C"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 672 B |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 678 B |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -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 <link>-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();
|
||||||
@@ -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();
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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<AktionsArt, { text: string; farbe: string }> = {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
titel="Übersicht"
|
||||||
|
badges={<Badge variante="messing">Prototyp · Beispieldaten</Badge>}
|
||||||
|
untertitel={
|
||||||
|
<>
|
||||||
|
Letzter Abgleich {relativ(letzterAbgleich, BEISPIEL_JETZT)} ·{" "}
|
||||||
|
<time dateTime={letzterAbgleich} className="font-mono text-[13px]">
|
||||||
|
{datumZeit(letzterAbgleich)}
|
||||||
|
</time>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
aktionen={
|
||||||
|
<>
|
||||||
|
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
|
||||||
|
<Button href="/zuweisungen" icon={<Route className="size-4" />}>
|
||||||
|
Host-zu-Host-Assistent
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
href="/schluessel"
|
||||||
|
variante="primaer"
|
||||||
|
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||||
|
>
|
||||||
|
Neuer Schlüssel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:gap-5 xl:grid-cols-4">
|
||||||
|
<Kennzahl
|
||||||
|
titel="Hosts"
|
||||||
|
wert={kennzahlen.hosts.wert}
|
||||||
|
hinweis={kennzahlen.hosts.hinweis}
|
||||||
|
href="/hosts"
|
||||||
|
/>
|
||||||
|
<Kennzahl
|
||||||
|
titel="Schlüssel"
|
||||||
|
wert={kennzahlen.schluessel.wert}
|
||||||
|
hinweis={kennzahlen.schluessel.hinweis}
|
||||||
|
href="/schluessel"
|
||||||
|
/>
|
||||||
|
<Kennzahl
|
||||||
|
titel="Abweichungen"
|
||||||
|
variante="gefahr"
|
||||||
|
wert={kennzahlen.abweichungen.wert}
|
||||||
|
hinweis={kennzahlen.abweichungen.hinweis}
|
||||||
|
href="/hosts"
|
||||||
|
/>
|
||||||
|
<Kennzahl
|
||||||
|
titel="Läuft in 30 Tagen ab"
|
||||||
|
variante="messing"
|
||||||
|
wert={kennzahlen.ablaufend.wert}
|
||||||
|
hinweis={kennzahlen.ablaufend.hinweis}
|
||||||
|
href="/schluessel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]">
|
||||||
|
<Karte titel="Hosts mit Abweichung" untertitel="Ergebnis des letzten Abgleichs">
|
||||||
|
<Tabelle
|
||||||
|
beschriftung="Hosts mit Abweichung"
|
||||||
|
spalten={[
|
||||||
|
{ titel: "Host" },
|
||||||
|
{ titel: "Benutzer" },
|
||||||
|
{ titel: "Befund" },
|
||||||
|
{ titel: "Aktion", rechts: true, versteckt: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{abweichungen.map((a) => (
|
||||||
|
<TabellenZeile key={`${a.host}-${a.benutzer}`}>
|
||||||
|
<TabellenZelle mono className="whitespace-nowrap">
|
||||||
|
{a.host}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<Statuspunkt status={a.status} text={a.befund} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle rechts>
|
||||||
|
<Link
|
||||||
|
href="/hosts"
|
||||||
|
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Prüfen<span className="sr-only"> – {a.host}</span>
|
||||||
|
</Link>
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Bald ablaufend" untertitel="Schlüssel mit Ablaufdatum in den nächsten 30 Tagen">
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{baldAblaufend.map((s) => {
|
||||||
|
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
||||||
|
return (
|
||||||
|
<li key={s.zweck} className="border-border border-b last:border-b-0">
|
||||||
|
<Link
|
||||||
|
href="/schluessel/beispiel"
|
||||||
|
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="text-text block truncate text-[14.5px] font-medium">{s.zweck}</span>
|
||||||
|
<span className="text-muted block truncate text-[13px]">
|
||||||
|
{s.person} · <span className="font-mono">{s.typ}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Badge variante="messing" className="shrink-0">
|
||||||
|
<time dateTime={s.ablauf}>{tage === 1 ? "morgen" : `in ${tage} Tagen`}</time>
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Letzte Aktionen"
|
||||||
|
aktionen={
|
||||||
|
<Link
|
||||||
|
href="/protokoll"
|
||||||
|
className="text-accent inline-flex min-h-11 items-center gap-1.5 text-[14px] font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Zum Audit-Log <ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ol className="flex flex-col">
|
||||||
|
{letzteAktionen.map((a) => (
|
||||||
|
<li
|
||||||
|
key={a.zeit}
|
||||||
|
className="border-border grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 border-b py-3 text-[14px] last:border-b-0 md:grid-cols-[8.5rem_6.5rem_1fr_auto] md:items-baseline"
|
||||||
|
>
|
||||||
|
<time dateTime={a.zeit} className="text-muted font-mono text-[12.5px] whitespace-nowrap">
|
||||||
|
{datum(a.zeit)} {uhrzeit(a.zeit)}
|
||||||
|
</time>
|
||||||
|
<span className={`${AKTION[a.art].farbe} font-medium md:order-none`}>{AKTION[a.art].text}</span>
|
||||||
|
<span className="text-text col-span-2 min-w-0 md:col-span-1">{a.beschreibung}</span>
|
||||||
|
<span className="text-muted col-span-2 text-[13px] md:col-span-1 md:text-right">
|
||||||
|
{a.benutzer}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.zweck }]} />}
|
||||||
|
titel={s.zweck}
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
|
<Badge mono>{s.typ}</Badge>
|
||||||
|
<Badge>{s.kategorie}</Badge>
|
||||||
|
<Badge>Vorlage: {s.vorlage}</Badge>
|
||||||
|
<Badge variante="messing">
|
||||||
|
<span>
|
||||||
|
läuft ab am <time dateTime={s.ablauf}>{datum(s.ablauf)}</time> · in {tage} Tagen
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
<Badge variante="neutral" className="text-muted">
|
||||||
|
Beispieldaten
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
aktionen={
|
||||||
|
<>
|
||||||
|
<Button icon={<PlugZap className="size-4" />}>Verbindung testen</Button>
|
||||||
|
<Button icon={<RotateCw className="size-4" />}>Rotieren</Button>
|
||||||
|
<Button variante="gefahr" icon={<Ban className="size-4" />}>
|
||||||
|
Widerrufen
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Karte as="div" innen="ohne">
|
||||||
|
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
||||||
|
<InfoFeld titel="Fingerprint">
|
||||||
|
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
|
||||||
|
</InfoFeld>
|
||||||
|
<InfoFeld titel="Erstellt">
|
||||||
|
<time dateTime={s.erstellt} className="font-mono text-[13px]">
|
||||||
|
{datumZeit(s.erstellt)}
|
||||||
|
</time>
|
||||||
|
<span className="text-muted block text-[13px]">von {s.erstelltVon}</span>
|
||||||
|
</InfoFeld>
|
||||||
|
<InfoFeld titel="Private Key">
|
||||||
|
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
|
||||||
|
<Lock className="text-ok size-3.5" aria-hidden />
|
||||||
|
verschlüsselt gespeichert ·
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
|
||||||
|
>
|
||||||
|
anzeigen (Re-Auth)
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</InfoFeld>
|
||||||
|
</dl>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<AusrollKarte
|
||||||
|
titel="Private Key ausgerollt auf"
|
||||||
|
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
||||||
|
eintraege={privatAusgerollt}
|
||||||
|
/>
|
||||||
|
<AusrollKarte
|
||||||
|
titel="Public Key ausgerollt auf"
|
||||||
|
untertitel="Konten, die diesen Schlüssel annehmen"
|
||||||
|
eintraege={publicAusgerollt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Dry-Run-Vorschau"
|
||||||
|
untertitel={
|
||||||
|
<>
|
||||||
|
Abgleich für <span className="font-mono">backup@srv-pve02</span> – noch nichts geschrieben
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DiffBox datei={dryRunDatei} zeilen={dryRunDiff} />
|
||||||
|
<fieldset className="mt-5">
|
||||||
|
<legend className="text-muted mb-1 text-[13px] font-medium">Zusätzlich ausführen</legend>
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{zusatzaktionen.map((z) => (
|
||||||
|
<li key={z.id}>
|
||||||
|
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-[14.5px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name={z.id}
|
||||||
|
defaultChecked={z.an}
|
||||||
|
className="accent-accent size-[18px] shrink-0"
|
||||||
|
/>
|
||||||
|
<span>{z.text}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</fieldset>
|
||||||
|
<div className="border-border mt-5 flex flex-wrap gap-2.5 border-t pt-5">
|
||||||
|
<Button variante="primaer" icon={<Check className="size-4" strokeWidth={2.4} />}>
|
||||||
|
Änderung anwenden
|
||||||
|
</Button>
|
||||||
|
<Button>Verwerfen</Button>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 px-5 py-4 sm:px-6">
|
||||||
|
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
|
||||||
|
<dd className="min-w-0">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AusrollKarte({
|
||||||
|
titel,
|
||||||
|
untertitel,
|
||||||
|
eintraege,
|
||||||
|
}: {
|
||||||
|
titel: string;
|
||||||
|
untertitel: string;
|
||||||
|
eintraege: Ausrollung[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Karte
|
||||||
|
titel={titel}
|
||||||
|
untertitel={untertitel}
|
||||||
|
aktionen={
|
||||||
|
<Button klein icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||||
|
Ausrollen
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ul className="flex flex-col gap-3">
|
||||||
|
{eintraege.map((e) => (
|
||||||
|
<li key={`${e.ziel}${e.pfad}`}>
|
||||||
|
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-text font-mono text-[13.5px] font-medium break-all">{e.ziel}</p>
|
||||||
|
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
|
||||||
|
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
||||||
|
</div>
|
||||||
|
<Statuspunkt status={e.status} text={e.statusText} className="shrink-0 sm:pt-0.5" />
|
||||||
|
</InnereBox>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1 +1,104 @@
|
|||||||
@import "tailwindcss";
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect x="2" y="2" width="28" height="28" rx="7" fill="none" stroke="#E8ECEF" stroke-width="3"/>
|
||||||
|
<circle cx="16" cy="13.5" r="4.6" fill="#F0B43C"/>
|
||||||
|
<path d="M13.8 15.5 L12 23.5 H20 L18.2 15.5 Z" fill="#F0B43C"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 285 B |
@@ -1,10 +1,27 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import type { ReactNode } from "react";
|
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";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: { default: "SSHKeyVault", template: "%s · SSHKeyVault" },
|
title: { default: "SSHKeyVault", template: "%s · SSHKeyVault" },
|
||||||
description: "Zentrale Verwaltung von SSH-Schlüsseln",
|
description: "Zentrale Verwaltung von SSH-Schlüsseln",
|
||||||
|
applicationName: "SSHKeyVault",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#0D1013",
|
||||||
|
colorScheme: "dark",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function Startseite() {
|
|
||||||
return <main>SSHKeyVault</main>;
|
|
||||||
}
|
|
||||||
@@ -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 },
|
||||||
|
];
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { Huelle } from "./Huelle";
|
||||||
|
export { Platzhalterseite } from "./Platzhalterseite";
|
||||||
|
export { ROLLEN_TEXT, type HuellenBenutzer, type Rolle } from "./typen";
|
||||||
@@ -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}/`);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Bildmarke (Tresortür mit Schlüsselloch) als Inline-SVG (#002).
|
||||||
|
*
|
||||||
|
* Inline statt <img src="/brand/logo.svg">, 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 (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
width={groesse}
|
||||||
|
height={groesse}
|
||||||
|
className={className}
|
||||||
|
{...(titel ? { role: "img", "aria-label": titel } : { "aria-hidden": true, focusable: false })}
|
||||||
|
>
|
||||||
|
<rect x="4" y="4" width="56" height="56" rx="14" fill="none" stroke={linie} strokeWidth="3.5" />
|
||||||
|
<circle
|
||||||
|
cx="32"
|
||||||
|
cy="32"
|
||||||
|
r="19"
|
||||||
|
fill="none"
|
||||||
|
stroke={linie}
|
||||||
|
strokeOpacity="0.28"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="3 3.2"
|
||||||
|
/>
|
||||||
|
<circle cx="13.5" cy="13.5" r="2.2" fill={linie} />
|
||||||
|
<circle cx="50.5" cy="13.5" r="2.2" fill={linie} />
|
||||||
|
<circle cx="13.5" cy="50.5" r="2.2" fill={linie} />
|
||||||
|
<circle cx="50.5" cy="50.5" r="2.2" fill={linie} />
|
||||||
|
<circle cx="32" cy="27" r="7.5" fill={messing} />
|
||||||
|
<path d="M28.6 30.5 L25.5 45 H38.5 L35.4 30.5 Z" fill={messing} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
className={clsx("font-display leading-none font-bold whitespace-nowrap", className)}
|
||||||
|
style={{ fontSize: groesse, letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
<span className="text-text">SSHKey</span>
|
||||||
|
<span className="text-accent">Vault</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kurzname immer kleingeschrieben in JetBrains Mono. */
|
||||||
|
export function Kurzname({ className }: { className?: string }) {
|
||||||
|
return <span className={clsx("font-mono lowercase", className)}>sshkv</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<span className={clsx("inline-flex items-center gap-2.5", className)}>
|
||||||
|
<Bildmarke groesse={bildGroesse} />
|
||||||
|
<Wortmarke groesse={textGroesse} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { Bildmarke } from "./Bildmarke";
|
||||||
|
export { Kurzname, Marke, Wortmarke } from "./Wortmarke";
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type BadgeVariante = "neutral" | "messing" | "ok" | "gefahr";
|
||||||
|
|
||||||
|
const VARIANTEN: Record<BadgeVariante, string> = {
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12.5px] leading-5 whitespace-nowrap",
|
||||||
|
mono ? "font-mono font-medium" : "font-medium",
|
||||||
|
VARIANTEN[variante],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<nav aria-label="Brotkrumen" className="text-muted text-[13.5px]">
|
||||||
|
<ol className="flex flex-wrap items-center gap-x-1 gap-y-1">
|
||||||
|
{pfad.map((e, i) => {
|
||||||
|
const letzter = i === pfad.length - 1;
|
||||||
|
return (
|
||||||
|
<Fragment key={i}>
|
||||||
|
<li className="min-w-0">
|
||||||
|
{e.href && !letzter ? (
|
||||||
|
<Link href={e.href} className="hover:text-text rounded transition-colors">
|
||||||
|
{e.titel}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span aria-current={letzter ? "page" : undefined} className={letzter ? "text-text" : ""}>
|
||||||
|
{e.titel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
{!letzter && (
|
||||||
|
<li aria-hidden className="flex">
|
||||||
|
<ChevronRight className="size-3.5" />
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ButtonVariante, string> = {
|
||||||
|
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<ComponentProps<"button">, keyof GemeinsameProps> & { href?: never };
|
||||||
|
type AlsLink = GemeinsameProps & Omit<ComponentProps<typeof Link>, 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 && (
|
||||||
|
<span aria-hidden className="-ml-0.5 inline-flex shrink-0">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if ("href" in rest && rest.href !== undefined) {
|
||||||
|
return (
|
||||||
|
<Link {...(rest as Omit<AlsLink, keyof GemeinsameProps>)} className={klassen}>
|
||||||
|
{inhalt}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const buttonRest = rest as Omit<AlsButton, keyof GemeinsameProps>;
|
||||||
|
return (
|
||||||
|
<button type="button" {...buttonRest} className={klassen}>
|
||||||
|
{inhalt}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<figure className={clsx("border-border bg-bg rounded-box min-w-0 overflow-hidden border", className)}>
|
||||||
|
{datei && (
|
||||||
|
<figcaption className="border-border text-muted border-b px-4 py-2.5 font-mono text-[12.5px] break-all">
|
||||||
|
{datei}
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
<ol className="py-2 font-mono text-[12.5px] leading-[1.6]">
|
||||||
|
{zeilen.map((z, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className={clsx(
|
||||||
|
"grid grid-cols-[1.75rem_1fr] px-2",
|
||||||
|
z.art === "entfernt" && "bg-danger-soft text-danger",
|
||||||
|
z.art === "hinzu" && "bg-[#0f1f1b] text-ok",
|
||||||
|
z.art === "kontext" && "text-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden className="text-center select-none">
|
||||||
|
{ZEICHEN[z.art]}
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">{LESBAR[z.art]}: </span>
|
||||||
|
<span className="min-w-0 pr-2 break-all whitespace-pre-wrap">{z.text || " "}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
{fuss && <div className="border-border border-t px-4 py-3">{fuss}</div>}
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Element
|
||||||
|
className={clsx(
|
||||||
|
"border-border bg-surface min-w-0 rounded-karte border",
|
||||||
|
innen === "normal" && "p-5 sm:p-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(titel || aktionen) && (
|
||||||
|
<KartenKopf titel={titel} untertitel={untertitel} aktionen={aktionen} rand={innen === "ohne"} />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</Element>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KartenKopf({
|
||||||
|
titel,
|
||||||
|
untertitel,
|
||||||
|
aktionen,
|
||||||
|
rand,
|
||||||
|
}: {
|
||||||
|
titel?: ReactNode;
|
||||||
|
untertitel?: ReactNode;
|
||||||
|
aktionen?: ReactNode;
|
||||||
|
rand: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={clsx(
|
||||||
|
"mb-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-2",
|
||||||
|
rand && "px-5 pt-5 sm:px-6 sm:pt-6",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{titel && <h2 className="text-text text-[17px] leading-snug font-semibold">{titel}</h2>}
|
||||||
|
{untertitel && <p className="text-muted mt-0.5 text-sm">{untertitel}</p>}
|
||||||
|
</div>
|
||||||
|
{aktionen && <div className="flex shrink-0 flex-wrap items-center gap-2">{aktionen}</div>}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Innere Box (--sidebar, 12 px Radius) – für Einträge innerhalb einer Karte. */
|
||||||
|
export function InnereBox({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={clsx("border-border bg-sidebar rounded-box min-w-0 border p-4", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<KennzahlVariante, string> = {
|
||||||
|
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 = (
|
||||||
|
<>
|
||||||
|
<p className="text-muted text-[14px] font-medium">{titel}</p>
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
"font-display mt-2 text-[34px] leading-none font-semibold tracking-tight tabular-nums",
|
||||||
|
WERTFARBE[variante],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{wert}
|
||||||
|
</p>
|
||||||
|
{hinweis && <p className="text-muted mt-2.5 text-[13px]">{hinweis}</p>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const klassen = "border-border bg-surface block min-w-0 rounded-karte border p-5 sm:p-6";
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className={clsx(klassen, "hover:border-border-strong transition-colors")}>
|
||||||
|
{inhalt}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div className={klassen}>{inhalt}</div>;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="border-border-strong rounded-karte flex flex-col items-center border border-dashed px-6 py-12 text-center">
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="bg-raised text-muted rounded-box mb-4 flex size-12 items-center justify-center"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="font-display text-text text-[18px] font-semibold">{titel}</p>
|
||||||
|
{text && <div className="text-muted mt-1.5 max-w-md text-[14.5px]">{text}</div>}
|
||||||
|
{aktion && <div className="mt-5">{aktion}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ReturnType<typeof setTimeout> | 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 (
|
||||||
|
<span className={clsx("inline-flex max-w-full min-w-0 items-start gap-1", className)}>
|
||||||
|
<code
|
||||||
|
className={clsx(
|
||||||
|
"text-text min-w-0 py-[11px] font-mono text-[13px] leading-[1.45]",
|
||||||
|
umbrechen ? "break-all" : "truncate",
|
||||||
|
)}
|
||||||
|
title={umbrechen ? undefined : wert}
|
||||||
|
>
|
||||||
|
{wert}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={kopieren}
|
||||||
|
className="text-muted hover:bg-raised hover:text-text rounded-feld inline-flex size-11 shrink-0 items-center justify-center transition-colors"
|
||||||
|
aria-label={`${beschriftung} kopieren`}
|
||||||
|
>
|
||||||
|
{kopiert ? <Check className="text-ok size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
|
||||||
|
</button>
|
||||||
|
<span role="status" className="sr-only">
|
||||||
|
{kopiert ? `${beschriftung} kopiert` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<header className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
{oben && <div className="mb-3">{oben}</div>}
|
||||||
|
<h1 className="text-text text-[26px] leading-tight font-semibold tracking-tight break-words sm:text-[30px]">
|
||||||
|
{titel}
|
||||||
|
</h1>
|
||||||
|
{badges && <div className="mt-3 flex flex-wrap items-center gap-2">{badges}</div>}
|
||||||
|
{untertitel && <div className="text-muted mt-1.5 text-[14.5px]">{untertitel}</div>}
|
||||||
|
</div>
|
||||||
|
{aktionen && <div className="flex shrink-0 flex-wrap gap-2.5">{aktionen}</div>}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HostStatus, { text: string; punkt: string; farbe: string }> = {
|
||||||
|
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 (
|
||||||
|
<span className={clsx("inline-flex items-center gap-2 text-[14px]", d.farbe, className)}>
|
||||||
|
<span aria-hidden className={clsx("size-2 shrink-0 rounded-full", d.punkt)} />
|
||||||
|
<span>{text ?? d.text}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
<div className={clsx("relative -mx-1 overflow-x-auto px-1", className)}>
|
||||||
|
<table className="w-full border-collapse text-left text-[14px]">
|
||||||
|
{beschriftung && <caption className="sr-only">{beschriftung}</caption>}
|
||||||
|
<thead>
|
||||||
|
<tr className="border-border-strong border-b">
|
||||||
|
{spalten.map((s, i) => (
|
||||||
|
<th
|
||||||
|
key={i}
|
||||||
|
scope="col"
|
||||||
|
className={clsx(
|
||||||
|
"text-muted px-3 pb-2.5 text-[13px] font-medium whitespace-nowrap first:pl-0 last:pr-0",
|
||||||
|
s.rechts && "text-right",
|
||||||
|
s.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.versteckt ? <span className="sr-only">{s.titel}</span> : s.titel}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{children}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabellenZeile({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return <tr className={clsx("border-border border-b last:border-b-0", className)}>{children}</tr>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabellenZelle({
|
||||||
|
children,
|
||||||
|
rechts,
|
||||||
|
mono,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
rechts?: boolean;
|
||||||
|
mono?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
className={clsx(
|
||||||
|
"px-3 py-3 align-middle first:pl-0 last:pr-0",
|
||||||
|
rechts && "text-right",
|
||||||
|
mono && "font-mono text-[13px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||