Files
d4rk_media/server/ui/src/api.ts
T
D4rkst3randClaude Opus 5 f7086c231c feat: Ordneruebersicht in der Galerie -- und der Proxy haelt keine Bilder mehr fest
ORDNERUEBERSICHT. Eine Spalte neben der Galerie mit allen Ordnern, auf allen
Ebenen, mit Anzahl und Groesse; ein Klick filtert. Neu dafuer:

- GET /api/dash/folders -- die Ordner werden in JavaScript zusammengezaehlt
  und nicht in SQL: fuer eine Baumansicht braucht es JEDE Ebene, und das waere
  in SQLite eine rekursive Abfrage, die niemand mehr liest.
- GET /api/dash/media?folder=... als EIGENER Filter neben der Suche. Die Suche
  findet mit LIKE %...% auch "alt/vehicles/adder.webp"; ein Ordner, der Dinge
  zeigt, die nicht darin liegen, ist keiner. Hier ist der Anfang verankert.
- Die LIKE-Sonderzeichen werden maskiert: ein Ordner "fotos_alt" wuerde sonst
  auch "fotosXalt" treffen, weil _ in LIKE ein beliebiges Zeichen ist.

Die Zahlen sind kumulativ -- "vehicles" enthaelt, was in "vehicles/showroom"
liegt. Alles andere ueberrascht in einer Baumansicht. Nachgemessen ueber drei
Ebenen: vehicles (24) > showroom (2) > tief (1), Filter und Zaehlung stimmen,
und nach dem Aufraeumen steht wieder vehicles (22).

Flache Liste mit Einrueckung statt aufklappbarem Baum: bei einer Handvoll
Ordner ist ein Aufklappmechanismus mehr Bedienung als Nutzen -- und was man
nicht aufklappen muss, kann man auch nicht zugeklappt uebersehen.

Dazu: der NPM-Zwischenspeicher ist zu. Der Schnipsel steht im Feld "Custom
Nginx Configuration"; NPM setzt ihn innerhalb des server-Blocks ein, eine
location ist dort also gueltig. Nachgemessen: cache-control kommt wieder von
uns, und ein ueberschriebenes Bild ist sofort das neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:22:51 +02:00

191 lines
6.4 KiB
TypeScript

// 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
}
/** Ein Ordner mit KUMULATIVEN Zahlen: "vehicles" enthält, was in
* "vehicles/showroom" liegt. `tiefe` ist die Einrückung im Baum. */
export type FolderRow = { folder: string; files: number; bytes: number; tiefe: number }
export type Folders = {
items: FolderRow[]
gesamt: { files: number; bytes: number }
wurzel: { files: number; bytes: number }
}
export type ThumbLauf = {
erzeugt: number
vorhanden: number
uebersprungen: number
fehler: number
gesamt: number
}
/** Die Adresse der Vorschau zu einem Pfad.
*
* Ob es sie gibt, weiss nur das Dateisystem — deshalb faellt die Galerie bei
* 404 auf das Vollbild zurueck, statt hier zu fragen. */
export const vorschauUrl = (path: string) =>
'/t/' + path.split('/').map(encodeURIComponent).join('/')
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, folder: string, limit: number, offset: number) =>
get<MediaPage>(
`/api/dash/media?query=${encodeURIComponent(query)}` +
`&folder=${encodeURIComponent(folder)}&limit=${limit}&offset=${offset}`,
),
folders: () => get<Folders>('/api/dash/folders'),
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'),
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
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}`),
}