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