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:
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user