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
+6 -2
View File
@@ -391,8 +391,12 @@ dieselbe Datei neu → abrufen über NPM: "ERSTE-FASSUNG" ← alt
alte**, obwohl Upload, Protokoll und Datenbank alle „fertig" sagen. Das ist
die Sorte Fehler, die man drei Ebenen tiefer sucht.
Der Gegenschnipsel gehört in NPM, Proxy Host `fivemanage.d4rkst3r.de`
*Advanced*:
**Behoben am 11.08.2026** und nachgemessen: die Datei trägt jetzt wieder
unsere eigene Kopfzeile (`cache-control: public, max-age=300`, kein `Expires`
von NPM mehr), und ein überschriebenes Bild kommt **sofort** durch. Der
Schnipsel steht im Feld *Custom Nginx Configuration* des Proxy-Hosts — NPM
setzt ihn dort innerhalb des `server`-Blocks ein (`{{ advanced_config }}`,
Zeile 28 von 52 in der Vorlage), eine `location` ist dort also gültig:
```nginx
# Dateien nicht zwischenspeichern -- sie koennen sich unter derselben
+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
+14 -2
View File
@@ -137,6 +137,16 @@ export type NewToken = {
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
@@ -160,10 +170,12 @@ export const api = {
changePassword: (current: string, next: string) =>
post<{ ok: true }>('/api/dash/auth/password', { current, next }),
media: (query: string, limit: number, offset: number) =>
media: (query: string, folder: string, limit: number, offset: number) =>
get<MediaPage>(
`/api/dash/media?query=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`,
`/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 }),
+91
View File
@@ -0,0 +1,91 @@
import { Folder, FolderOpen, Layers } from 'lucide-react'
import type { Folders } from '../api'
import { bytes } from '../format'
/** Die Ordnerübersicht neben der Galerie.
*
* Kein aufklappbarer Baum, sondern eine flache Liste mit Einrückung. Bei
* einer Handvoll Ordner ist ein Aufklappmechanismus mehr Bedienung als
* Nutzen — und was man nicht aufklappen muss, kann man auch nicht zugeklappt
* übersehen. */
export function Ordner({
daten,
gewaehlt,
onWahl,
}: {
daten: Folders | null
gewaehlt: string
onWahl: (ordner: string) => void
}) {
if (!daten) return null
const zeile = (
aktiv: boolean,
kind: React.ReactNode,
name: string,
anzahl: number,
groesse: number,
tiefe = 0,
onClick?: () => void,
) => (
<button
key={name + tiefe}
onClick={onClick}
className={`flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left text-sm
transition-colors ${
aktiv
? 'bg-flaeche-hoch text-schrift'
: 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift'
}`}
style={{ paddingLeft: `${0.5 + tiefe * 0.85}rem` }}
>
<span className={aktiv ? 'text-akzent' : ''}>{kind}</span>
<span className="flex-1 truncate font-mono text-xs" title={name}>
{name}
</span>
<span className="shrink-0 text-[11px] tabular-nums text-schrift-leise">
{anzahl}
</span>
<span className="w-14 shrink-0 text-right text-[11px] tabular-nums text-schrift-leise">
{bytes(groesse)}
</span>
</button>
)
return (
<aside className="w-full shrink-0 self-start rounded-lg border border-rand bg-flaeche p-2 lg:w-72">
<h2 className="px-2 py-1 text-xs font-semibold text-schrift-leise">Ordner</h2>
{zeile(
gewaehlt === '',
<Layers size={14} />,
'Alle',
daten.gesamt.files,
daten.gesamt.bytes,
0,
() => onWahl(''),
)}
{daten.items.map((o) =>
zeile(
gewaehlt === o.folder,
gewaehlt === o.folder ? <FolderOpen size={14} /> : <Folder size={14} />,
// Nur das letzte Stück anzeigen — der Weg dorthin steht
// schon in der Einrückung. Der volle Pfad hängt im title.
o.folder.split('/').pop() ?? o.folder,
o.files,
o.bytes,
o.tiefe,
() => onWahl(o.folder),
),
)}
{daten.wurzel.files > 0 && (
<p className="mt-2 px-2 text-[11px] text-schrift-leise">
{daten.wurzel.files} Datei(en) liegen ohne Ordner direkt an der Wurzel zu
sehen unter Alle".
</p>
)}
</aside>
)
}
+40 -5
View File
@@ -12,16 +12,19 @@ import {
Trash2,
X,
} from 'lucide-react'
import { api, vorschauUrl, type MediaItem, type MediaPage } from '../api'
import { api, vorschauUrl, type Folders, type MediaItem, type MediaPage } from '../api'
import { run, useStore } from '../store'
import { bytes, istBild, zeit } from '../format'
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
import { Ordner } from '../components/Ordner'
const PRO_SEITE = 60
export function Galerie() {
const note = useStore((s) => s.note)
const [suche, setSuche] = useState('')
const [ordner, setOrdner] = useState('')
const [ordnerDaten, setOrdnerDaten] = useState<Folders | null>(null)
const [seite, setSeite] = useState(0)
const [daten, setDaten] = useState<MediaPage | null>(null)
const [laedt, setLaedt] = useState(true)
@@ -51,7 +54,7 @@ export function Galerie() {
const meine = ++lauf.current
setLaedt(true)
try {
const seiteDaten = await api.media(sucheAktiv, PRO_SEITE, seite * PRO_SEITE)
const seiteDaten = await api.media(sucheAktiv, ordner, PRO_SEITE, seite * PRO_SEITE)
if (meine !== lauf.current) return
setDaten(seiteDaten)
} catch (err) {
@@ -60,12 +63,28 @@ export function Galerie() {
} finally {
if (meine === lauf.current) setLaedt(false)
}
}, [sucheAktiv, seite, note])
}, [sucheAktiv, ordner, seite, note])
useEffect(() => {
void laden()
}, [laden])
// Die Ordnerzahlen aendern sich beim Loeschen und beim Hochladen. Sie
// haengen deshalb am selben Auslöser wie die Liste -- eine Uebersicht, die
// "21 Dateien" behauptet, waehrend daneben 19 stehen, ist schlimmer als
// keine.
const ordnerLaden = useCallback(async () => {
try {
setOrdnerDaten(await api.folders())
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
}
}, [note])
useEffect(() => {
void ordnerLaden()
}, [ordnerLaden])
const items = daten?.items ?? []
const gesamt = daten?.total ?? 0
const seiten = Math.max(1, Math.ceil(gesamt / PRO_SEITE))
@@ -116,6 +135,7 @@ export function Galerie() {
if (ergebnis) {
setGewaehlt(new Set())
void laden()
void ordnerLaden()
}
}
@@ -125,7 +145,18 @@ export function Galerie() {
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 lg:flex-row">
<Ordner
daten={ordnerDaten}
gewaehlt={ordner}
onWahl={(o) => {
setOrdner(o)
setSeite(0)
setGewaehlt(new Set())
}}
/>
<div className="flex min-w-0 flex-1 flex-col gap-4">
{/* Kopfzeile: Suche, Auswahl, Löschen */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-56 flex-1">
@@ -190,7 +221,9 @@ export function Galerie() {
kind={<ImageOff size={28} className="text-schrift-leise/50" />}
text={
sucheAktiv
? `Nichts gefunden für "${sucheAktiv}".`
? `Nichts gefunden für "${sucheAktiv}"${ordner ? ` in ${ordner}/` : ''}.`
: ordner
? `In ${ordner}/ liegt nichts.`
: 'Noch keine Datei hochgeladen.'
}
/>
@@ -321,6 +354,8 @@ export function Galerie() {
</div>
)}
</div>
<Nachfrage
offen={loeschen !== null}
titel={loeschen?.length === 1 ? 'Datei löschen?' : `${loeschen?.length} Dateien löschen?`}