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>
This commit is contained in:
2026-08-11 18:22:51 +02:00
co-authored by Claude Opus 5
parent aa2575bdf4
commit f7086c231c
5 changed files with 224 additions and 12 deletions
+72 -2
View File
@@ -238,8 +238,30 @@ dashRoutes.get('/media', (c) => {
const limit = Math.min(Number(c.req.query('limit') ?? 60) || 60, 200)
const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0)
const where = query ? 'WHERE path LIKE ?' : ''
const params = query ? [`%${query}%`] : []
// Der Ordner ist ein EIGENER Filter und nicht bloss eine Suche nach
// "vehicles/": die Suche findet mit LIKE %…% auch "alt/vehicles/adder.webp",
// und dann zeigt der Ordner Dinge, die nicht darin liegen. Hier wird der
// Anfang verankert.
//
// Die Sonderzeichen von LIKE werden maskiert: ein Ordner mit _ im Namen
// (etwa "fotos_alt") wuerde sonst auch "fotosXalt" treffen — _ steht in
// LIKE fuer ein beliebiges Zeichen.
const folder = c.req.query('folder')?.trim().replace(/\/+$/, '') ?? ''
const maskiert = (s: string) => s.replace(/[\\%_]/g, (z) => `\\${z}`)
const bedingungen: string[] = []
const params: unknown[] = []
if (query) {
bedingungen.push(`path LIKE ? ESCAPE '\\'`)
params.push(`%${maskiert(query)}%`)
}
if (folder) {
bedingungen.push(`path LIKE ? ESCAPE '\\'`)
params.push(`${maskiert(folder)}/%`)
}
const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : ''
const total = db.prepare(`SELECT COUNT(*) AS n FROM media ${where}`).get(...params) as {
n: number
@@ -258,6 +280,54 @@ dashRoutes.get('/media', (c) => {
})
})
/** Alle Ordner, auf allen Ebenen, mit Anzahl und Groesse.
*
* In JavaScript zusammengezaehlt und nicht in SQL: fuer eine Baumansicht
* braucht es JEDE Ebene ("vehicles" und "vehicles/showroom"), und das waere
* in SQLite eine rekursive Abfrage, die niemand mehr liest. Bei ein paar
* tausend Zeilen ist die Schleife hier schneller getippt und schneller
* verstanden — und wenn es je Millionen werden, faellt es beim Messen auf und
* nicht vorher.
*
* Die Zahlen sind KUMULATIV: "vehicles" enthaelt, was in "vehicles/showroom"
* liegt. Alles andere ueberrascht in einer Baumansicht. */
dashRoutes.get('/folders', (c) => {
const rows = db.prepare('SELECT path, size FROM media').all() as {
path: string
size: number
}[]
const ordner = new Map<string, { files: number; bytes: number; tiefe: number }>()
for (const row of rows) {
const teile = row.path.split('/')
// Das letzte Stueck ist der Dateiname und kein Ordner.
for (let i = 1; i < teile.length; i++) {
const pfad = teile.slice(0, i).join('/')
const eintrag = ordner.get(pfad) ?? { files: 0, bytes: 0, tiefe: i - 1 }
eintrag.files += 1
eintrag.bytes += row.size
ordner.set(pfad, eintrag)
}
}
const items = [...ordner.entries()]
.map(([folder, wert]) => ({ folder, ...wert }))
// Alphabetisch und nicht nach Groesse: eine Baumansicht, deren
// Reihenfolge sich beim naechsten Upload aendert, ist keine.
.sort((a, b) => a.folder.localeCompare(b.folder, 'de'))
// Dateien direkt an der Wurzel haben keinen Ordner und wuerden sonst
// unsichtbar. Sie stehen im Dashboard unter "Alle".
const wurzel = rows.filter((r) => !r.path.includes('/'))
return c.json({
items,
gesamt: { files: rows.length, bytes: rows.reduce((s, r) => s + r.size, 0) },
wurzel: { files: wurzel.length, bytes: wurzel.reduce((s, r) => s + r.size, 0) },
})
})
dashRoutes.delete('/media/:id', async (c) => {
const id = Number(c.req.param('id'))
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined