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
+102
View File
@@ -0,0 +1,102 @@
import { create } from 'zustand'
import { api, ApiError, type User } from './api'
// -- Rückmeldungen -------------------------------------------------------------
//
// DIE EINE REGEL: jeder Knopf gibt Rückmeldung, auch der erfolgreiche. Deshalb
// steht die Meldungsliste hier im Laden und nicht in irgendeiner Seite — sie
// gehört zu jeder von ihnen.
export type NoteKind = 'ok' | 'fehler' | 'hinweis'
export type Note = { id: number; kind: NoteKind; text: string }
let nextNoteId = 1
type State = {
user: User | null
/** Solange true, weiß noch niemand, ob eine Sitzung besteht — in dieser
* Zeit darf weder das Anmeldeformular noch das Dashboard aufblitzen. */
checking: boolean
notes: Note[]
checkSession: () => Promise<void>
login: (username: string, password: string) => Promise<void>
logout: () => Promise<void>
note: (kind: NoteKind, text: string) => void
dismiss: (id: number) => void
}
export const useStore = create<State>((set, get) => ({
user: null,
checking: true,
notes: [],
async checkSession() {
try {
const { user } = await api.me()
set({ user, checking: false })
} catch (err) {
// 401 ist hier kein Fehler, sondern die Antwort "noch nicht
// angemeldet". Alles andere ist einer und wird gesagt.
if (err instanceof ApiError && err.status === 401) {
set({ user: null, checking: false })
return
}
set({ user: null, checking: false })
get().note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
}
},
async login(username, password) {
const { user } = await api.login(username, password)
set({ user })
get().note('ok', `Angemeldet als ${user.username}.`)
},
async logout() {
await api.logout()
set({ user: null })
// Die Erfolgsmeldung kommt hier NICHT: der Aufrufer geht über `run`,
// und der meldet. Beides hieße zwei Zettel für einen Klick.
},
note(kind, text) {
const id = nextNoteId++
set((s) => ({ notes: [...s.notes, { id, kind, text }] }))
// Fehler bleiben stehen, bis jemand sie wegklickt. Ein Fehler, der von
// allein verschwindet, ist ein Fehler, den niemand gelesen hat.
if (kind !== 'fehler') {
setTimeout(() => get().dismiss(id), 4000)
}
},
dismiss(id) {
set((s) => ({ notes: s.notes.filter((n) => n.id !== id) }))
},
}))
/** Eine Aktion ausführen und IMMER etwas dazu sagen.
*
* Der Erfolgstext wird mitgegeben, der Fehlertext kommt vom Dienst. Wer
* darüber geht, kann keinen stillen Knopf bauen — und genau darum geht es. */
export async function run<T>(
action: () => Promise<T>,
erfolg: string | ((result: T) => string),
): Promise<T | null> {
const { note } = useStore.getState()
try {
const result = await action()
note('ok', typeof erfolg === 'function' ? erfolg(result) : erfolg)
return result
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
// Eine abgelaufene Sitzung ist kein gewöhnlicher Fehler: sie führt
// zurück aufs Anmeldeformular, statt jeden weiteren Knopf mit
// "nicht angemeldet" zu quittieren.
if (err instanceof ApiError && err.status === 401) {
useStore.setState({ user: null })
}
return null
}
}