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 alte**, obwohl Upload, Protokoll und Datenbank alle „fertig" sagen. Das ist
die Sorte Fehler, die man drei Ebenen tiefer sucht. die Sorte Fehler, die man drei Ebenen tiefer sucht.
Der Gegenschnipsel gehört in NPM, Proxy Host `fivemanage.d4rkst3r.de` **Behoben am 11.08.2026** und nachgemessen: die Datei trägt jetzt wieder
*Advanced*: 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 ```nginx
# Dateien nicht zwischenspeichern -- sie koennen sich unter derselben # 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 limit = Math.min(Number(c.req.query('limit') ?? 60) || 60, 200)
const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0) const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0)
const where = query ? 'WHERE path LIKE ?' : '' // Der Ordner ist ein EIGENER Filter und nicht bloss eine Suche nach
const params = query ? [`%${query}%`] : [] // "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 { const total = db.prepare(`SELECT COUNT(*) AS n FROM media ${where}`).get(...params) as {
n: number 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) => { dashRoutes.delete('/media/:id', async (c) => {
const id = Number(c.req.param('id')) const id = Number(c.req.param('id'))
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined 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 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 = { export type ThumbLauf = {
erzeugt: number erzeugt: number
vorhanden: number vorhanden: number
@@ -160,10 +170,12 @@ export const api = {
changePassword: (current: string, next: string) => changePassword: (current: string, next: string) =>
post<{ ok: true }>('/api/dash/auth/password', { current, next }), 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>( 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}`), deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`),
deleteMediaBulk: (ids: number[]) => deleteMediaBulk: (ids: number[]) =>
post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }), 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>
)
}
+41 -6
View File
@@ -12,16 +12,19 @@ import {
Trash2, Trash2,
X, X,
} from 'lucide-react' } 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 { run, useStore } from '../store'
import { bytes, istBild, zeit } from '../format' import { bytes, istBild, zeit } from '../format'
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine' import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
import { Ordner } from '../components/Ordner'
const PRO_SEITE = 60 const PRO_SEITE = 60
export function Galerie() { export function Galerie() {
const note = useStore((s) => s.note) const note = useStore((s) => s.note)
const [suche, setSuche] = useState('') const [suche, setSuche] = useState('')
const [ordner, setOrdner] = useState('')
const [ordnerDaten, setOrdnerDaten] = useState<Folders | null>(null)
const [seite, setSeite] = useState(0) const [seite, setSeite] = useState(0)
const [daten, setDaten] = useState<MediaPage | null>(null) const [daten, setDaten] = useState<MediaPage | null>(null)
const [laedt, setLaedt] = useState(true) const [laedt, setLaedt] = useState(true)
@@ -51,7 +54,7 @@ export function Galerie() {
const meine = ++lauf.current const meine = ++lauf.current
setLaedt(true) setLaedt(true)
try { 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 if (meine !== lauf.current) return
setDaten(seiteDaten) setDaten(seiteDaten)
} catch (err) { } catch (err) {
@@ -60,12 +63,28 @@ export function Galerie() {
} finally { } finally {
if (meine === lauf.current) setLaedt(false) if (meine === lauf.current) setLaedt(false)
} }
}, [sucheAktiv, seite, note]) }, [sucheAktiv, ordner, seite, note])
useEffect(() => { useEffect(() => {
void laden() void laden()
}, [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 items = daten?.items ?? []
const gesamt = daten?.total ?? 0 const gesamt = daten?.total ?? 0
const seiten = Math.max(1, Math.ceil(gesamt / PRO_SEITE)) const seiten = Math.max(1, Math.ceil(gesamt / PRO_SEITE))
@@ -116,6 +135,7 @@ export function Galerie() {
if (ergebnis) { if (ergebnis) {
setGewaehlt(new Set()) setGewaehlt(new Set())
void laden() void laden()
void ordnerLaden()
} }
} }
@@ -125,7 +145,18 @@ export function Galerie() {
} }
return ( 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 */} {/* Kopfzeile: Suche, Auswahl, Löschen */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-56 flex-1"> <div className="relative min-w-56 flex-1">
@@ -190,8 +221,10 @@ export function Galerie() {
kind={<ImageOff size={28} className="text-schrift-leise/50" />} kind={<ImageOff size={28} className="text-schrift-leise/50" />}
text={ text={
sucheAktiv sucheAktiv
? `Nichts gefunden für "${sucheAktiv}".` ? `Nichts gefunden für "${sucheAktiv}"${ordner ? ` in ${ordner}/` : ''}.`
: 'Noch keine Datei hochgeladen.' : ordner
? `In ${ordner}/ liegt nichts.`
: 'Noch keine Datei hochgeladen.'
} }
/> />
) : ( ) : (
@@ -321,6 +354,8 @@ export function Galerie() {
</div> </div>
)} )}
</div>
<Nachfrage <Nachfrage
offen={loeschen !== null} offen={loeschen !== null}
titel={loeschen?.length === 1 ? 'Datei löschen?' : `${loeschen?.length} Dateien löschen?`} titel={loeschen?.length === 1 ? 'Datei löschen?' : `${loeschen?.length} Dateien löschen?`}