diff --git a/ROADMAP.md b/ROADMAP.md index 70b68a7..a61c606 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 5dbc2a7..0c5d9a8 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -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() + + 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 diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 1941956..d896226 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -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( - `/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('/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 }), diff --git a/server/ui/src/components/Ordner.tsx b/server/ui/src/components/Ordner.tsx new file mode 100644 index 0000000..12c0f3f --- /dev/null +++ b/server/ui/src/components/Ordner.tsx @@ -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, + ) => ( + + ) + + return ( + + ) +} diff --git a/server/ui/src/pages/Galerie.tsx b/server/ui/src/pages/Galerie.tsx index 3aa77c4..f30de9e 100644 --- a/server/ui/src/pages/Galerie.tsx +++ b/server/ui/src/pages/Galerie.tsx @@ -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(null) const [seite, setSeite] = useState(0) const [daten, setDaten] = useState(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 ( -
+
+ { + setOrdner(o) + setSeite(0) + setGewaehlt(new Set()) + }} + /> + +
{/* Kopfzeile: Suche, Auswahl, Löschen */}
@@ -190,8 +221,10 @@ export function Galerie() { kind={} text={ sucheAktiv - ? `Nichts gefunden für "${sucheAktiv}".` - : 'Noch keine Datei hochgeladen.' + ? `Nichts gefunden für "${sucheAktiv}"${ordner ? ` in ${ordner}/` : ''}.` + : ordner + ? `In ${ordner}/ liegt nichts.` + : 'Noch keine Datei hochgeladen.' } /> ) : ( @@ -321,6 +354,8 @@ export function Galerie() {
)} +
+