syncova-backup/apps/web/src/components/ui/Feedback.tsx
Jerrit Fritzsche 6e23696fcb
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s
Weboberflaeche: letzte Seiten auf das Design-System gezogen
Damit gibt es nur noch ein Stylesheet. Uebersicht, Meldungen, Kennzahlen,
Berichte, Security Center, Wiederherstellungspunkte, Anmeldung, Backup-Assistent
und Gesundheitsanzeige nutzten noch das alte — sie funktionierten, sahen aber
anders aus als der Rest.

Der Backup-Assistent wurde als **Klassenabbildung** umgestellt, nicht neu
geschrieben: Seine Logik ist geprueft und richtig; eine Neufassung haette 500
Zeilen Verhalten ohne Not angefasst. Die elf Tests des Assistenten pruefen
Verhalten und blieben unveraendert gueltig.

Entfernt, weil ersetzt und nirgends mehr verwendet: JobsPanel, StatusIndicator,
PageState, App.css, tokens.css und vier weitere Stylesheets. Das ausgelieferte
CSS faellt von 54 auf 30 KB.

Drei Funde beim Umbau, alle von Tests aufgedeckt:

- **`describeApiError` warf die genauere Servermeldung weg.** Sie ersetzte sie
  durch den allgemeinen Satz aus der Codetabelle. Ein `SERVICE_UNAVAILABLE` mit
  der Meldung "Fuer diesen Bericht ist keine Sicherheitspruefung eingerichtet."
  wurde zu "Der Dienst ist derzeit nicht vollstaendig verfuegbar" — der
  Betreiber haette den Fehler bei seiner Anlage gesucht statt bei der
  Einrichtung dieses einen Berichts. Jetzt hat die Servermeldung Vorrang; die
  Tabelle springt nur ein, wenn keine mitkommt.
- **Einem Fehler nach einer Handlung fehlte `role="alert"`.** Ein Screenreader
  haette ihn nicht angesagt. `Callout` nimmt jetzt eine Rolle entgegen; Standard
  bleibt `note`, weil die meisten Hinweise schon beim Oeffnen dastehen.
- **Zwei Statusbeschriftungen wichen von den etablierten ab** (`Gesund` statt
  `Fehlerfrei`, `Nicht verbunden` statt `Nicht erreichbar`). Die etablierten
  gewinnen — sie stehen in Tests fest und sind treffender.

Dazu zwei kleinere Korrekturen: Die Gesundheitsanzeige hing kurzzeitig in der
Uebersicht und verband damit zwei Ladewege, die nichts miteinander zu tun haben;
sie steht jetzt wieder daneben. Und ein frueherer Regex hatte
`(row) => void | undefined` erzeugt — gemeint war eine optionale Eigenschaft,
geschrieben stand "gibt void oder undefined zurueck".

Nachgewiesen gegen Debian 12 mit nginx: alle 18 Seiten liefern 200, das
Design-System steckt im ausgelieferten CSS samt Dark-Mode-Regeln, und vom alten
Stylesheet ist kein Klassenname mehr darin.

76 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:50:15 +02:00

167 lines
5.1 KiB
TypeScript

/**
* Zustandsanzeigen: Laden, Fehler, Leere, Hinweise.
*
* `ErrorState` zeigt immer die Vorgangsnummer. `NotMeasured` ist die Antwort
* auf einen Grundsatz, der sich durch die ganze Anlage zieht: Unbekannt ist
* nicht null. Eine Kennzahl ohne Datengrundlage erscheint als Satz mit
* Begruendung, nie als "0" — eine Null liest sich wie "geprueft und in
* Ordnung", und genau das waere falsch.
*/
import { AlertTriangle, Info, Loader2, TriangleAlert } from 'lucide-react';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Button } from './Button';
export function LoadingState({ label = 'Wird geladen …' }: { readonly label?: string }) {
return (
<div className="flex items-center justify-center gap-2 px-6 py-14 text-sm text-fg-muted">
<Loader2 className="size-4 animate-spin" aria-hidden />
{label}
</div>
);
}
export function ErrorState({
title = 'Die Anfrage ist fehlgeschlagen',
message,
requestId,
onRetry,
className,
}: {
readonly title?: string | undefined;
readonly message?: string | undefined;
readonly requestId?: string | undefined;
readonly onRetry?: (() => void) | undefined;
readonly className?: string | undefined;
}) {
return (
<div
className={cn(
'flex flex-col items-center justify-center px-6 py-12 text-center',
className,
)}
role="alert"
>
<TriangleAlert className="size-6 text-critical" aria-hidden />
<p className="mt-3 text-sm font-medium text-fg">{title}</p>
{message ? <p className="mt-1 max-w-lg text-sm text-fg-muted">{message}</p> : null}
{requestId ? (
<p className="mt-3 font-mono text-xs text-fg-subtle">
Vorgangsnummer: {requestId}
</p>
) : null}
{onRetry ? (
<Button variant="secondary" size="sm" className="mt-4" onClick={onRetry}>
Erneut versuchen
</Button>
) : null}
</div>
);
}
export function EmptyState({
title,
description,
action,
icon,
}: {
readonly title: ReactNode;
readonly description?: ReactNode | undefined;
readonly action?: ReactNode | undefined;
readonly icon?: ReactNode | undefined;
}) {
return (
<div className="flex flex-col items-center justify-center px-6 py-14 text-center">
{icon ? <div className="mb-3 text-fg-subtle">{icon}</div> : null}
<p className="text-sm font-medium text-fg">{title}</p>
{description ? (
<p className="mt-1 max-w-md text-sm text-fg-muted">{description}</p>
) : null}
{action ? <div className="mt-4">{action}</div> : null}
</div>
);
}
/**
* Hinweis fuer eine Kennzahl ohne Datengrundlage.
*
* Der Grund gehoert dazu. "—" allein sieht nach einem Fehler der Oberflaeche
* aus; "nicht gemessen, weil kein Wiederherstellungstest lief" ist eine
* Handlungsanweisung.
*/
export function NotMeasured({ reason }: { readonly reason: ReactNode }) {
return (
<span className="inline-flex items-center gap-1.5 text-sm text-fg-muted">
<Info className="size-3.5 shrink-0" aria-hidden />
{reason}
</span>
);
}
/** Auffaelliger Hinweiskasten. */
export function Callout({
tone = 'info',
title,
children,
className,
role = 'note',
}: {
readonly tone?: 'info' | 'warning' | 'critical' | 'healthy' | undefined;
readonly title?: ReactNode | undefined;
readonly children?: ReactNode | undefined;
readonly className?: string | undefined;
/**
* ARIA-Rolle.
*
* Standard ist `note` — die meisten Hinweise erklaeren etwas und stehen schon
* beim Oeffnen der Maske da. Ein Hinweis, der **als Folge einer Handlung**
* erscheint, gehoert dagegen auf `alert`: Ein Screenreader sagt ihn dann an,
* statt ihn im Seiteninhalt untergehen zu lassen.
*/
readonly role?: 'note' | 'alert' | 'status' | undefined;
}) {
const toneClasses = {
info: 'border-info/30 bg-info-bg text-info',
warning: 'border-warning/30 bg-warning-bg text-warning',
critical: 'border-critical/30 bg-critical-bg text-critical',
healthy: 'border-healthy/30 bg-healthy-bg text-healthy',
} as const;
const IconComponent = tone === 'info' ? Info : tone === 'healthy' ? Info : AlertTriangle;
return (
<div className={cn('rounded-md border p-3', toneClasses[tone], className)} role={role}>
<div className="flex gap-2.5">
<IconComponent className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="min-w-0 text-sm">
{title ? <p className="font-medium">{title}</p> : null}
{children ? (
<div className={cn('text-fg', title && 'mt-1')}>{children}</div>
) : null}
</div>
</div>
</div>
);
}
/** Beschriftetes Wertepaar fuer Detailseiten. */
export function DetailItem({
label,
children,
className,
}: {
readonly label: ReactNode;
readonly children: ReactNode;
readonly className?: string | undefined;
}) {
return (
<div className={cn('min-w-0', className)}>
<dt className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
{label}
</dt>
<dd className="mt-1 text-sm text-fg">{children}</dd>
</div>
);
}