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";