feat: das Dashboard -- und die eine Regel steht im Code, nicht im Vorsatz

Schritt 4. React 18, TypeScript, Vite 5, Tailwind 4 (CSS-first), Lucide,
Zustand; kein Konsta. Quelle in server/ui, Build nach server/web (gitignored),
im Abbild von einer eigenen Docker-Stufe gebaut.

JEDER KNOPF GIBT RUECKMELDUNG -- und zwar erzwungen, nicht vorgenommen:

- api.ts haelt den EINZIGEN fetch und GENAU EINE Fehlerklasse. Der
  Fivemanage-Fehler war, zwei zu haben: geworfen wurde `new Error`, geprueft
  auf `instanceof ApiError`, und damit verschwand jeder Fehlschlag lautlos.
- store.ts haelt run(): Aktion rein, Erfolgstext rein, und es meldet bei
  Erfolg diesen und bei Fehlschlag den Text DES DIENSTES. Wer darueber geht,
  kann keinen stillen Knopf bauen.
- Jeder wartende Knopf ist gesperrt und zeigt einen Kreisel. Der zweite Klick
  eine Sekunde spaeter war der Weg zu neunzehn gleichnamigen Organisationen.
- Fehlermeldungen bleiben stehen, bis jemand sie wegklickt. Ein Fehler, der
  von allein verschwindet, ist einer, den niemand gelesen hat.

@vitejs/plugin-react ist auf ^4 festgenagelt: die 6 verlangt vite ^8. In den
peerDependencies nachgesehen -- Vite 5.4.21 liegt in der Schnittmenge von
plugin-react@4 und @tailwindcss/vite@4.

Am laufenden Container nachgemessen: / kommt als text/html, assets/*.js als
text/javascript, *.css als text/css (die MIME-Falle aus Schritt 1 traegt), und
/tokens, /speicher, /konto liefern dieselbe index.html -- die SPA-Rueckfall-
route tut, wofuer sie gebaut wurde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 17:38:38 +02:00
co-authored by Claude Opus 5
parent 65828b526c
commit 6c8ead8e15
22 changed files with 4644 additions and 15 deletions
+190
View File
@@ -0,0 +1,190 @@
import { useEffect, useState, type ReactNode } from 'react'
import { Loader2 } from 'lucide-react'
// -- Knopf ---------------------------------------------------------------------
type KnopfArt = 'haupt' | 'still' | 'gefahr'
const arten: Record<KnopfArt, string> = {
haupt: 'bg-akzent text-grund hover:bg-akzent/85 border-akzent',
still: 'bg-flaeche-hoch text-schrift hover:bg-rand border-rand',
gefahr: 'bg-schlecht/15 text-schlecht hover:bg-schlecht/25 border-schlecht/40',
}
/** Ein Knopf, der beim Arbeiten aussieht, als arbeite er.
*
* `wartet` sperrt ihn und zeigt den Kreisel. Ohne das ist der zweite Klick
* einen Wimpernschlag später eine zweite Anfrage — bei "Organisation anlegen"
* war genau das der Weg zu neunzehn gleichnamigen Einträgen. */
export function Knopf({
art = 'still',
wartet = false,
kind,
className = '',
disabled,
...rest
}: {
art?: KnopfArt
wartet?: boolean
kind?: ReactNode
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...rest}
disabled={disabled || wartet}
className={`inline-flex items-center justify-center gap-2 rounded-md border px-3 py-1.5
text-sm font-medium transition-colors disabled:cursor-not-allowed
disabled:opacity-50 ${arten[art]} ${className}`}
>
{wartet && <Loader2 size={15} className="animate-spin" />}
{kind}
</button>
)
}
// -- Eingabefeld ---------------------------------------------------------------
export function Feld({
label,
hinweis,
className = '',
...rest
}: { label?: string; hinweis?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<label className="block">
{label && (
<span className="mb-1 block text-xs font-medium text-schrift-leise">{label}</span>
)}
<input
{...rest}
className={`w-full rounded-md border border-rand bg-grund px-3 py-1.5 text-sm
text-schrift placeholder:text-schrift-leise/60
focus:border-akzent focus:outline-none ${className}`}
/>
{hinweis && <span className="mt-1 block text-xs text-schrift-leise">{hinweis}</span>}
</label>
)
}
// -- Karte ---------------------------------------------------------------------
export function Karte({
titel,
rechts,
children,
}: {
titel?: string
rechts?: ReactNode
children: ReactNode
}) {
return (
<section className="rounded-lg border border-rand bg-flaeche">
{(titel || rechts) && (
<header className="flex items-center justify-between gap-3 border-b border-rand px-4 py-2.5">
<h2 className="text-sm font-semibold">{titel}</h2>
{rechts}
</header>
)}
<div className="p-4">{children}</div>
</section>
)
}
// -- Nachfrage vor dem Löschen -------------------------------------------------
/** Ein Dialog, der sagt, WAS gelöscht wird — nicht "Sind Sie sicher?".
*
* Löschen ist die einzige Stelle hier, die sich nicht rückgängig machen
* lässt: die Datei ist danach von der Platte weg, und es gibt keine
* Sicherung, aus der sie zurückkäme. */
export function Nachfrage({
offen,
titel,
text,
bestaetigung,
wartet,
onJa,
onNein,
}: {
offen: boolean
titel: string
text: ReactNode
bestaetigung: string
wartet?: boolean
onJa: () => void
onNein: () => void
}) {
// Escape schließt. Ein Dialog, aus dem man nur mit der Maus herauskommt,
// ist ein Dialog, den man versehentlich bestätigt.
useEffect(() => {
if (!offen) return
const ab = (e: KeyboardEvent) => {
if (e.key === 'Escape') onNein()
}
window.addEventListener('keydown', ab)
return () => window.removeEventListener('keydown', ab)
}, [offen, onNein])
if (!offen) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onNein}
>
<div
className="w-full max-w-md rounded-lg border border-rand bg-flaeche shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="border-b border-rand px-4 py-3 text-sm font-semibold">{titel}</h3>
<div className="px-4 py-4 text-sm text-schrift-leise">{text}</div>
<div className="flex justify-end gap-2 border-t border-rand px-4 py-3">
<Knopf onClick={onNein} kind="Abbrechen" />
<Knopf art="gefahr" wartet={wartet} onClick={onJa} kind={bestaetigung} />
</div>
</div>
</div>
)
}
// -- Leerzustand ---------------------------------------------------------------
export function Leer({ text, kind }: { text: string; kind?: ReactNode }) {
return (
<div className="flex flex-col items-center gap-2 py-12 text-center text-sm text-schrift-leise">
{kind}
<p>{text}</p>
</div>
)
}
// -- In die Zwischenablage -----------------------------------------------------
/** Kopieren MIT Rückmeldung. Ein Kopierknopf ohne sichtbare Antwort ist der
* Urtyp des kaputt aussehenden Knopfes: man klickt zweimal, weil man nicht
* weiß, ob der erste Klick etwas getan hat. */
export function useKopieren() {
const [kopiert, setKopiert] = useState<string | null>(null)
useEffect(() => {
if (kopiert === null) return
const t = setTimeout(() => setKopiert(null), 1500)
return () => clearTimeout(t)
}, [kopiert])
async function kopieren(text: string, marke: string) {
try {
await navigator.clipboard.writeText(text)
setKopiert(marke)
return true
} catch {
// navigator.clipboard gibt es nur im sicheren Kontext. Über https
// ist das gegeben; wer den Dienst über eine nackte LAN-Adresse
// aufruft, bekommt hier ein ehrliches Nein statt eines Knopfes,
// der scheinbar nichts tut.
return false
}
}
return { kopiert, kopieren }
}
+48
View File
@@ -0,0 +1,48 @@
import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react'
import { useStore, type NoteKind } from '../store'
const gestalt: Record<NoteKind, { farbe: string; kind: typeof Info }> = {
ok: { farbe: 'border-gut/40 bg-gut/10 text-gut', kind: CheckCircle2 },
fehler: { farbe: 'border-schlecht/40 bg-schlecht/10 text-schlecht', kind: AlertTriangle },
hinweis: { farbe: 'border-akzent/40 bg-akzent/10 text-akzent', kind: Info },
}
/** Die Rückmeldungen, unten rechts.
*
* Erfolge verschwinden nach vier Sekunden von allein, Fehler bleiben stehen,
* bis jemand sie wegklickt — das entscheidet der Laden, nicht diese Ansicht. */
export function Meldungen() {
const notes = useStore((s) => s.notes)
const dismiss = useStore((s) => s.dismiss)
if (notes.length === 0) return null
return (
<div
className="fixed right-4 bottom-4 z-60 flex w-[min(26rem,calc(100vw-2rem))] flex-col gap-2"
role="status"
aria-live="polite"
>
{notes.map((n) => {
const { farbe, kind: Kind } = gestalt[n.kind]
return (
<div
key={n.id}
className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm
shadow-lg backdrop-blur ${farbe}`}
>
<Kind size={16} className="mt-0.5 shrink-0" />
<span className="flex-1 break-words text-schrift">{n.text}</span>
<button
onClick={() => dismiss(n.id)}
className="shrink-0 rounded p-0.5 text-schrift-leise hover:text-schrift"
aria-label="Meldung schließen"
>
<X size={14} />
</button>
</div>
)
})}
</div>
)
}