From da0607522efbc5ec62483f986b9f5db6d656c8e4 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:20:26 +0200 Subject: [PATCH] feat(ui): Marke und Grundkomponenten (#002) Bildmarke als Inline-SVG und Wortmarke, dazu Karte, Button, Badge, Statuspunkt, Tabelle, Kennzahl, Diff-Box, Breadcrumb, Seitenkopf, Leerzustand und MonoKopieren. Die Bausteine kapseln die Designregeln (44 px Klickflaeche, Status nie nur ueber Farbe, Tabellen scrollen im eigenen Container), damit spaetere Seiten sie nicht neu erfinden. Nur MonoKopieren ist Client-Komponente, weil sie die Zwischenablage braucht. Co-Authored-By: Claude Opus 5.5 --- src/components/marke/Bildmarke.tsx | 49 +++++++++++++++++ src/components/marke/Wortmarke.tsx | 38 +++++++++++++ src/components/marke/index.ts | 2 + src/components/ui/Badge.tsx | 38 +++++++++++++ src/components/ui/Breadcrumb.tsx | 38 +++++++++++++ src/components/ui/Button.tsx | 64 ++++++++++++++++++++++ src/components/ui/DiffBox.tsx | 55 +++++++++++++++++++ src/components/ui/Karte.tsx | 76 ++++++++++++++++++++++++++ src/components/ui/Kennzahl.tsx | 51 ++++++++++++++++++ src/components/ui/Leerzustand.tsx | 30 +++++++++++ src/components/ui/MonoKopieren.tsx | 64 ++++++++++++++++++++++ src/components/ui/Seitenkopf.tsx | 32 +++++++++++ src/components/ui/Statuspunkt.tsx | 41 ++++++++++++++ src/components/ui/Tabelle.tsx | 85 ++++++++++++++++++++++++++++++ src/components/ui/index.ts | 11 ++++ 15 files changed, 674 insertions(+) create mode 100644 src/components/marke/Bildmarke.tsx create mode 100644 src/components/marke/Wortmarke.tsx create mode 100644 src/components/marke/index.ts create mode 100644 src/components/ui/Badge.tsx create mode 100644 src/components/ui/Breadcrumb.tsx create mode 100644 src/components/ui/Button.tsx create mode 100644 src/components/ui/DiffBox.tsx create mode 100644 src/components/ui/Karte.tsx create mode 100644 src/components/ui/Kennzahl.tsx create mode 100644 src/components/ui/Leerzustand.tsx create mode 100644 src/components/ui/MonoKopieren.tsx create mode 100644 src/components/ui/Seitenkopf.tsx create mode 100644 src/components/ui/Statuspunkt.tsx create mode 100644 src/components/ui/Tabelle.tsx create mode 100644 src/components/ui/index.ts diff --git a/src/components/marke/Bildmarke.tsx b/src/components/marke/Bildmarke.tsx new file mode 100644 index 0000000..e553e18 --- /dev/null +++ b/src/components/marke/Bildmarke.tsx @@ -0,0 +1,49 @@ +/** + * Bildmarke (Tresortür mit Schlüsselloch) als Inline-SVG (#002). + * + * Inline statt , damit die Marke ohne zusätzliche Anfrage + * sofort steht und die Farben aus den CSS-Tokens kommen. Geometrie identisch mit + * public/brand/logo.svg – Änderungen bitte an beiden Stellen und `pnpm icons` laufen lassen. + */ +export function Bildmarke({ + groesse = 34, + titel, + className, +}: { + /** Kantenlänge in px. */ + groesse?: number; + /** Zugänglicher Name; ohne Titel gilt die Marke als Schmuck (neben der Wortmarke). */ + titel?: string; + className?: string; +}) { + const linie = "var(--text)"; + const messing = "var(--accent)"; + return ( + + + + + + + + + + + ); +} diff --git a/src/components/marke/Wortmarke.tsx b/src/components/marke/Wortmarke.tsx new file mode 100644 index 0000000..ab247eb --- /dev/null +++ b/src/components/marke/Wortmarke.tsx @@ -0,0 +1,38 @@ +import { clsx } from "clsx"; +import { Bildmarke } from "./Bildmarke"; + +/** „SSHKey“ in Textfarbe, „Vault“ in Messing – Space Grotesk 700 (#002). */ +export function Wortmarke({ groesse = 22, className }: { groesse?: number; className?: string }) { + return ( + + SSHKey + Vault + + ); +} + +/** Kurzname immer kleingeschrieben in JetBrains Mono. */ +export function Kurzname({ className }: { className?: string }) { + return sshkv; +} + +/** Bildmarke + Wortmarke nebeneinander, Standardgrößen wie in der Seitenleiste (34/22 px). */ +export function Marke({ + bildGroesse = 34, + textGroesse = 22, + className, +}: { + bildGroesse?: number; + textGroesse?: number; + className?: string; +}) { + return ( + + + + + ); +} diff --git a/src/components/marke/index.ts b/src/components/marke/index.ts new file mode 100644 index 0000000..8036dda --- /dev/null +++ b/src/components/marke/index.ts @@ -0,0 +1,2 @@ +export { Bildmarke } from "./Bildmarke"; +export { Kurzname, Marke, Wortmarke } from "./Wortmarke"; diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 0000000..0d5b078 --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -0,0 +1,38 @@ +import { clsx } from "clsx"; +import type { ReactNode } from "react"; + +export type BadgeVariante = "neutral" | "messing" | "ok" | "gefahr"; + +const VARIANTEN: Record = { + neutral: "bg-raised text-text border-border-strong", + // Messing auf --accent-soft: Ablaufhinweise und Vorlagen-Kennzeichnung. + messing: "bg-accent-soft text-accent border-[#5a4519]", + ok: "bg-[#12261f] text-ok border-[#1f4a3d]", + gefahr: "bg-danger-soft text-danger border-danger-border", +}; + +/** Pillenförmiges Etikett; `mono` für technische Werte wie Schlüsseltypen (#002). */ +export function Badge({ + variante = "neutral", + mono = false, + children, + className, +}: { + variante?: BadgeVariante; + mono?: boolean; + children: ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/src/components/ui/Breadcrumb.tsx b/src/components/ui/Breadcrumb.tsx new file mode 100644 index 0000000..05dba65 --- /dev/null +++ b/src/components/ui/Breadcrumb.tsx @@ -0,0 +1,38 @@ +import { ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { Fragment } from "react"; + +export type BreadcrumbEintrag = { titel: string; href?: string }; + +/** Brotkrumen-Navigation; der letzte Eintrag ist die aktuelle Seite (#002). */ +export function Breadcrumb({ pfad }: { pfad: BreadcrumbEintrag[] }) { + return ( + + ); +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..9d76fff --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,64 @@ +import { clsx } from "clsx"; +import Link from "next/link"; +import type { ComponentProps, ReactNode } from "react"; + +export type ButtonVariante = "primaer" | "sekundaer" | "gefahr"; + +const VARIANTEN: Record = { + primaer: "bg-accent text-bg font-semibold border-accent hover:bg-[#f5c25c]", + sekundaer: "bg-surface text-text font-medium border-border-strong hover:bg-raised", + gefahr: "bg-transparent text-danger font-medium border-danger-border hover:bg-danger-soft", +}; + +/** Gemeinsame Klassen – auch für Elemente, die wie ein Button aussehen sollen. */ +export function buttonKlassen(variante: ButtonVariante = "sekundaer", klein = false, className?: string) { + return clsx( + // 44 px Mindesthöhe als Klickfläche (WCAG 2.5.5), auch in der kleinen Variante. + "inline-flex min-h-11 items-center justify-center gap-2 rounded-box border px-4 text-[14px] leading-tight whitespace-nowrap transition-colors", + "disabled:cursor-not-allowed disabled:opacity-50", + klein && "px-3 text-[13px]", + VARIANTEN[variante], + className, + ); +} + +type GemeinsameProps = { + variante?: ButtonVariante; + /** Lucide-Icon o. Ä. vor dem Text. */ + icon?: ReactNode; + klein?: boolean; + className?: string; + children: ReactNode; +}; + +type AlsButton = GemeinsameProps & Omit, keyof GemeinsameProps> & { href?: never }; +type AlsLink = GemeinsameProps & Omit, keyof GemeinsameProps> & { href: string }; + +/** Button in den Varianten primär/sekundär/Gefahr; mit `href` wird daraus ein Link. */ +export function Button(props: AlsButton | AlsLink) { + const { variante = "sekundaer", icon, klein = false, className, children, ...rest } = props; + const klassen = buttonKlassen(variante, klein, className); + const inhalt = ( + <> + {icon && ( + + {icon} + + )} + {children} + + ); + if ("href" in rest && rest.href !== undefined) { + return ( + )} className={klassen}> + {inhalt} + + ); + } + const buttonRest = rest as Omit; + return ( + + ); +} diff --git a/src/components/ui/DiffBox.tsx b/src/components/ui/DiffBox.tsx new file mode 100644 index 0000000..9d966c8 --- /dev/null +++ b/src/components/ui/DiffBox.tsx @@ -0,0 +1,55 @@ +import { clsx } from "clsx"; +import type { ReactNode } from "react"; + +export type DiffZeile = { art: "hinzu" | "entfernt" | "kontext"; text: string }; + +const ZEICHEN = { hinzu: "+", entfernt: "−", kontext: " " } as const; +const LESBAR = { hinzu: "hinzugefügt", entfernt: "entfernt", kontext: "unverändert" } as const; + +/** + * Diff-Vorschau (Dry-Run) für authorized_keys, ~/.ssh/config & Co. (#002). + * Entfernte Zeilen Koralle auf --danger-soft, hinzugefügte in --ok. Lange Schlüsselzeilen + * brechen um statt die Seite zu verbreitern – ein Public Key ist sonst 400+ Zeichen breit. + */ +export function DiffBox({ + zeilen, + datei, + fuss, + className, +}: { + zeilen: DiffZeile[]; + /** Pfad der Datei als Kopfzeile, z. B. „backup@srv-pve01:~/.ssh/authorized_keys“. */ + datei?: string; + fuss?: ReactNode; + className?: string; +}) { + return ( +
+ {datei && ( +
+ {datei} +
+ )} +
    + {zeilen.map((z, i) => ( +
  1. + + {ZEICHEN[z.art]} + + {LESBAR[z.art]}: + {z.text || " "} +
  2. + ))} +
+ {fuss &&
{fuss}
} +
+ ); +} diff --git a/src/components/ui/Karte.tsx b/src/components/ui/Karte.tsx new file mode 100644 index 0000000..c1b6bfd --- /dev/null +++ b/src/components/ui/Karte.tsx @@ -0,0 +1,76 @@ +import { clsx } from "clsx"; +import type { ReactNode } from "react"; + +/** + * Karte: --surface, 1 px Rahmen, 16 px Radius, 22–24 px Innenabstand (#002). + * Mit `titel` entsteht ein Kopf mit optionalen Aktionen rechts. + */ +export function Karte({ + titel, + untertitel, + aktionen, + children, + className, + innen = "normal", + as: Element = "section", +}: { + titel?: ReactNode; + untertitel?: ReactNode; + aktionen?: ReactNode; + children?: ReactNode; + className?: string; + /** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */ + innen?: "normal" | "ohne"; + as?: "section" | "div" | "article"; +}) { + return ( + + {(titel || aktionen) && ( + + )} + {children} + + ); +} + +function KartenKopf({ + titel, + untertitel, + aktionen, + rand, +}: { + titel?: ReactNode; + untertitel?: ReactNode; + aktionen?: ReactNode; + rand: boolean; +}) { + return ( +
+
+ {titel &&

{titel}

} + {untertitel &&

{untertitel}

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

{titel}

+

+ {wert} +

+ {hinweis &&

{hinweis}

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

{titel}

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

+ {titel} +

+ {badges &&
{badges}
} + {untertitel &&
{untertitel}
} +
+ {aktionen &&
{aktionen}
} +
+ ); +} diff --git a/src/components/ui/Statuspunkt.tsx b/src/components/ui/Statuspunkt.tsx new file mode 100644 index 0000000..b55ea74 --- /dev/null +++ b/src/components/ui/Statuspunkt.tsx @@ -0,0 +1,41 @@ +import { clsx } from "clsx"; + +export type HostStatus = "synchron" | "drift" | "unerreichbar" | "unbekannt"; + +/* + * Status nie nur über Farbe (#002): Punktform und Text tragen die Information mit, + * damit sie auch bei Farbfehlsichtigkeit und in Graustufen-Ausdrucken lesbar bleibt. + */ +const DARSTELLUNG: Record = { + synchron: { text: "synchron", punkt: "bg-ok", farbe: "text-ok" }, + drift: { text: "Drift", punkt: "bg-danger", farbe: "text-danger" }, + unerreichbar: { + text: "nicht erreichbar", + punkt: "border-[1.5px] border-muted bg-transparent", + farbe: "text-muted", + }, + unbekannt: { + text: "unbekannt", + punkt: "border-[1.5px] border-dashed border-muted bg-transparent", + farbe: "text-muted", + }, +}; + +export function Statuspunkt({ + status, + text, + className, +}: { + status: HostStatus; + /** Abweichender Text (z. B. „2 fremde Schlüssel“); ohne ihn der Standardtext des Status. */ + text?: string; + className?: string; +}) { + const d = DARSTELLUNG[status]; + return ( + + + {text ?? d.text} + + ); +} diff --git a/src/components/ui/Tabelle.tsx b/src/components/ui/Tabelle.tsx new file mode 100644 index 0000000..306b25f --- /dev/null +++ b/src/components/ui/Tabelle.tsx @@ -0,0 +1,85 @@ +import { clsx } from "clsx"; +import type { ReactNode } from "react"; + +export type Spalte = { + titel: ReactNode; + /** Rechtsbündig für Zahlen und Aktionen. */ + rechts?: boolean; + /** Nur für Screenreader (z. B. Aktionsspalte ohne sichtbare Überschrift). */ + versteckt?: boolean; + className?: string; +}; + +/** + * Tabelle mit eigenem horizontalem Scrollbereich (#002): Breite Inhalte schieben nie die + * ganze Seite, auf 390 px scrollt nur die Tabelle. Kopf mit --border-strong-Linie. + */ +export function Tabelle({ + spalten, + children, + beschriftung, + className, +}: { + spalten: Spalte[]; + children: ReactNode; + /** Zugänglicher Name der Tabelle (caption, visuell versteckt). */ + beschriftung?: string; + className?: string; +}) { + return ( + // relative: sonst entkommen absolut positionierte Kinder (sr-only-Texte) dem + // Scrollbereich und verbreitern die ganze Seite. +
+ + {beschriftung && } + + + {spalten.map((s, i) => ( + + ))} + + + {children} +
{beschriftung}
+ {s.versteckt ? {s.titel} : s.titel} +
+
+ ); +} + +export function TabellenZeile({ children, className }: { children: ReactNode; className?: string }) { + return {children}; +} + +export function TabellenZelle({ + children, + rechts, + mono, + className, +}: { + children: ReactNode; + rechts?: boolean; + mono?: boolean; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..d6c20a9 --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1,11 @@ +export { Badge, type BadgeVariante } from "./Badge"; +export { Breadcrumb, type BreadcrumbEintrag } from "./Breadcrumb"; +export { Button, buttonKlassen, type ButtonVariante } from "./Button"; +export { DiffBox, type DiffZeile } from "./DiffBox"; +export { InnereBox, Karte } from "./Karte"; +export { Kennzahl, type KennzahlVariante } from "./Kennzahl"; +export { Leerzustand } from "./Leerzustand"; +export { MonoKopieren } from "./MonoKopieren"; +export { Seitenkopf } from "./Seitenkopf"; +export { Statuspunkt, type HostStatus } from "./Statuspunkt"; +export { Tabelle, TabellenZeile, TabellenZelle, type Spalte } from "./Tabelle";