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
+162
View File
@@ -0,0 +1,162 @@
// Der EINZIGE Ort, an dem fetch steht.
//
// Und die Stelle, an der der Fivemanage-Anlauf gescheitert ist: dessen
// http-util.ts warf jeden Fehlschlag als `new Error(…)`, die Hooks prüften auf
// `instanceof ApiError` — und damit wurde jeder Fehler lautlos verschluckt.
// Deshalb gibt es hier genau eine Fehlerklasse, und `request` wirft
// ausschließlich diese. Wer sie fängt, hat immer einen Text zum Anzeigen.
export class ApiError extends Error {
readonly status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
/** Kein Netz, kein Server, DNS tot — alles, was nie beim Dienst ankam. */
export class NetworkError extends ApiError {
constructor(message: string) {
super(message, 0)
this.name = 'NetworkError'
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
let response: Response
try {
response = await fetch(path, {
// Das Sitzungs-Cookie ist httpOnly; ohne diese Zeile schickt der
// Browser es nicht mit, und jede Antwort wäre 401.
credentials: 'same-origin',
...init,
headers: {
...(init.body ? { 'content-type': 'application/json' } : {}),
...init.headers,
},
})
} catch (err) {
// fetch wirft nur, wenn die Anfrage gar nicht stattgefunden hat. Das
// ist ein anderer Fall als "der Dienst sagt nein", und er verdient
// einen anderen Satz.
throw new NetworkError(
`Der Dienst ist nicht erreichbar (${err instanceof Error ? err.message : 'unbekannt'})`,
)
}
const text = await response.text()
let data: unknown = null
if (text) {
try {
data = JSON.parse(text)
} catch {
// Der Dienst antwortet auf allen API-Wegen mit JSON. Kommt hier
// etwas anderes an, ist der Proxy davor oder die Sitzung schuld —
// dann steht der Anfang der Antwort in der Meldung, statt dass
// "unerwarteter Fehler" auf dem Bildschirm steht.
if (!response.ok) {
throw new ApiError(
`HTTP ${response.status}: ${text.slice(0, 140)}`,
response.status,
)
}
throw new ApiError('Die Antwort war kein JSON', response.status)
}
}
if (!response.ok) {
const message =
data && typeof data === 'object' && typeof (data as any).error === 'string'
? (data as any).error
: `HTTP ${response.status}`
throw new ApiError(message, response.status)
}
return data as T
}
const get = <T,>(path: string) => request<T>(path)
const post = <T,>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) })
const del = <T,>(path: string) => request<T>(path, { method: 'DELETE' })
// -- Die Formen, die der Dienst wirklich liefert -------------------------------
//
// Abgeschrieben aus routes/dash.ts, nicht erfunden. Wo der Server
// Datenbankspalten durchreicht, heißen sie hier genauso (can_delete,
// created_at) — ein hübscherer Name auf halbem Weg wäre eine Übersetzung, die
// beim nächsten Feld vergessen wird.
export type User = { id: number; username: string }
export type MediaItem = {
id: number
path: string
size: number
sha256: string
mime: string
token_id: number | null
created_at: number
updated_at: number
url: string
}
export type MediaPage = {
total: number
limit: number
offset: number
items: MediaItem[]
}
export type Stats = {
files: number
bytes: number
tokens: number
folders: { folder: string; files: number; bytes: number }[]
publicUrl: string
}
export type TokenRow = {
id: number
name: string
prefix: string
can_delete: number
created_at: number
last_used_at: number | null
}
/** Der Klartext kommt genau einmal — beim Anlegen. Danach steht nur noch der
* Hash in der Datenbank, und auch der Dienst kann ihn nicht mehr zeigen. */
export type NewToken = {
id: number
name: string
prefix: string
canDelete: boolean
token: string
}
export const api = {
login: (username: string, password: string) =>
post<{ user: User }>('/api/dash/auth/login', { username, password }),
logout: () => post<{ ok: true }>('/api/dash/auth/logout'),
me: () => get<{ user: User }>('/api/dash/auth/me'),
changePassword: (current: string, next: string) =>
post<{ ok: true }>('/api/dash/auth/password', { current, next }),
media: (query: string, limit: number, offset: number) =>
get<MediaPage>(
`/api/dash/media?query=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`,
),
deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`),
deleteMediaBulk: (ids: number[]) =>
post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }),
stats: () => get<Stats>('/api/dash/stats'),
tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'),
createToken: (name: string, prefix: string, canDelete: boolean) =>
post<NewToken>('/api/dash/tokens', { name, prefix, canDelete }),
deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`),
}