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>
191 lines
6.4 KiB
TypeScript
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}`),
|
|
}
|