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:
+14
-2
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,8 +221,10 @@ export function Galerie() {
|
||||
kind={<ImageOff size={28} className="text-schrift-leise/50" />}
|
||||
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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<Nachfrage
|
||||
offen={loeschen !== null}
|
||||
titel={loeschen?.length === 1 ? 'Datei löschen?' : `${loeschen?.length} Dateien löschen?`}
|
||||
|
||||
Reference in New Issue
Block a user