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