Files
d4rk_media/server/ui/src/pages/Galerie.tsx
T
D4rkst3randClaude Opus 5 aa2575bdf4 feat: Sicherung, Anmeldebremse, Vorschaubilder -- und Schritt 5
SICHERUNG (tools/sichern.ps1, taeglich 04:30 als geplante Aufgabe).
Die Datenbank wird nicht kopiert, sondern ueber SQLites eigene
Sicherungsschnittstelle herausgeholt (server/src/backup.ts): im WAL-Modus
liegt das Zuletzte noch nicht in media.db, und selbst alle drei Dateien zu
kopieren ist nicht sicher, wenn waehrenddessen geschrieben wird. Die Bilder
kommen aus einem NUR LESEND eingehaengten Volume dazu.

Und sie prueft sich selbst: auspacken, Datenbank oeffnen, Medien/Token/
Benutzer zaehlen, mit dem laufenden Dienst vergleichen, sonst Fehler. Einmal
wirklich zurueckgespielt -- leeres Volume, zweiter Dienst, Anmeldung mit dem
echten Passwort, Bild abgerufen, Byte fuer Byte identisch. Eine Sicherung, die
nie zurueckgespielt wurde, ist eine Hoffnung.

ANMELDEBREMSE. Das Formular steht oeffentlich, und scrypt macht einen Versuch
teuer -- aber teuer ist nicht selten. Fuenf freie Versuche je Adresse, dann
Sperre ab 30 s mit Verdopplung bis 15 min, 429 samt Retry-After und einem Text,
der sagt wie lange. Erfolg setzt zurueck. Nur im Speicher: wer sich aussperrt,
startet den Container neu. Durchgemessen bis zur Erholung.

VORSCHAUBILDER. sharp erzeugt beim Upload eine 320er WebP-Fassung unter
/data/thumbs, ausgeliefert unter /t/<pfad>. Gemessen: 131502 -> 13078 Bytes,
Faktor 10; eine Galerieseite faellt von 7,5 MB auf 766 KB. KEINE Spalte in der
Datenbank -- ob es eine Vorschau gibt, sagt das Dateisystem, und die Galerie
faellt bei 404 aufs Vollbild zurueck. Eine zweite Wahrheit, die auseinander-
laufen kann, gibt es damit gar nicht erst. Ein Knopf zieht Fehlendes nach und
nennt Zahlen statt "fertig".

SCHRITT 5. Die Fivemanage-Dateien liegen in legacy/ mit Erklaerung; unsere
docker-compose.media.yml heisst jetzt docker-compose.yml. Drei Volumes und drei
Abbilder geloescht, rund 670 MB -- nachgezaehlt war vorher, dass kein Bild
darin lag.

Nebenbefund: `npm i sharp` scheitert auf diesem Rechner nicht an sharp, sondern
an better-sqlite3 -- der Host laeuft auf Node 24 (ABI 137), node-gyp uebernimmt
und findet keine Bauwerkzeuge. Genau die Falle aus dem Dockerfile. Eingetragen
wurde mit --package-lock-only, gebaut wird im Abbild auf Node 22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:00:14 +02:00

351 lines
15 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import {
Check,
ChevronLeft,
ChevronRight,
Copy,
ExternalLink,
FileQuestion,
ImageOff,
Loader2,
Search,
Trash2,
X,
} from 'lucide-react'
import { api, vorschauUrl, 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'
const PRO_SEITE = 60
export function Galerie() {
const note = useStore((s) => s.note)
const [suche, setSuche] = useState('')
const [seite, setSeite] = useState(0)
const [daten, setDaten] = useState<MediaPage | null>(null)
const [laedt, setLaedt] = useState(true)
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
const [loeschen, setLoeschen] = useState<MediaItem[] | null>(null)
const [loescht, setLoescht] = useState(false)
const { kopiert, kopieren } = useKopieren()
// Die Suche wartet einen Moment ab. Ohne das schickt jeder Tastendruck
// eine Anfrage, und bei "adder" sind das fünf, von denen vier veraltet
// ankommen können.
const [sucheAktiv, setSucheAktiv] = useState('')
useEffect(() => {
const t = setTimeout(() => {
setSucheAktiv(suche)
setSeite(0)
}, 250)
return () => clearTimeout(t)
}, [suche])
// Welche Anfrage die neueste ist. Eine ältere, die später ankommt, darf
// die neuere nicht überschreiben — sonst steht nach schnellem Tippen das
// Ergebnis von vorletzter Eingabe auf dem Schirm.
const lauf = useRef(0)
const laden = useCallback(async () => {
const meine = ++lauf.current
setLaedt(true)
try {
const seiteDaten = await api.media(sucheAktiv, PRO_SEITE, seite * PRO_SEITE)
if (meine !== lauf.current) return
setDaten(seiteDaten)
} catch (err) {
if (meine !== lauf.current) return
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
if (meine === lauf.current) setLaedt(false)
}
}, [sucheAktiv, seite, note])
useEffect(() => {
void laden()
}, [laden])
const items = daten?.items ?? []
const gesamt = daten?.total ?? 0
const seiten = Math.max(1, Math.ceil(gesamt / PRO_SEITE))
function umschalten(id: number) {
setGewaehlt((alt) => {
const neu = new Set(alt)
if (neu.has(id)) neu.delete(id)
else neu.add(id)
return neu
})
}
const alleGewaehlt = items.length > 0 && items.every((i) => gewaehlt.has(i.id))
function alleUmschalten() {
setGewaehlt((alt) => {
const neu = new Set(alt)
if (alleGewaehlt) items.forEach((i) => neu.delete(i.id))
else items.forEach((i) => neu.add(i.id))
return neu
})
}
async function loeschenAusfuehren() {
if (!loeschen) return
setLoescht(true)
const ergebnis =
loeschen.length === 1
? await run(
() => api.deleteMedia(loeschen[0]!.id),
(r) => `${r.deleted} gelöscht.`,
)
: await run(
() => api.deleteMediaBulk(loeschen.map((i) => i.id)),
(r) =>
r.count === loeschen.length
? `${r.count} Dateien gelöscht.`
: // Der Dienst überspringt, was er nicht findet.
// Diese Lücke zu verschweigen wäre genau die
// Sorte Rückmeldung, die hier nicht vorkommt.
`${r.count} von ${loeschen.length} gelöscht — der Rest war schon weg.`,
)
setLoescht(false)
setLoeschen(null)
if (ergebnis) {
setGewaehlt(new Set())
void laden()
}
}
async function adresseKopieren(item: MediaItem) {
const ok = await kopieren(item.url, String(item.id))
if (!ok) note('fehler', 'Die Zwischenablage ist gesperrt — nur über https erreichbar.')
}
return (
<div className="flex 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">
<Search
size={15}
className="absolute top-1/2 left-3 -translate-y-1/2 text-schrift-leise"
/>
<input
value={suche}
onChange={(e) => setSuche(e.target.value)}
placeholder="Pfad durchsuchen, z. B. vehicles/"
className="w-full rounded-md border border-rand bg-flaeche py-1.5 pr-8 pl-9
text-sm placeholder:text-schrift-leise/60 focus:border-akzent
focus:outline-none"
/>
{suche && (
<button
onClick={() => setSuche('')}
className="absolute top-1/2 right-2 -translate-y-1/2 text-schrift-leise hover:text-schrift"
aria-label="Suche leeren"
>
<X size={14} />
</button>
)}
</div>
<span className="text-sm text-schrift-leise">
{laedt ? (
<Loader2 size={15} className="animate-spin" />
) : (
<>
{gesamt} {gesamt === 1 ? 'Datei' : 'Dateien'}
{sucheAktiv && ' gefunden'}
</>
)}
</span>
{items.length > 0 && (
<Knopf
onClick={alleUmschalten}
kind={alleGewaehlt ? 'Auswahl aufheben' : 'Seite wählen'}
/>
)}
{gewaehlt.size > 0 && (
<Knopf
art="gefahr"
onClick={() => setLoeschen(items.filter((i) => gewaehlt.has(i.id)))}
kind={
<>
<Trash2 size={15} />
{gewaehlt.size} löschen
</>
}
/>
)}
</div>
{/* Das Raster */}
{items.length === 0 && !laedt ? (
<Leer
kind={<ImageOff size={28} className="text-schrift-leise/50" />}
text={
sucheAktiv
? `Nichts gefunden für "${sucheAktiv}".`
: 'Noch keine Datei hochgeladen.'
}
/>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
{items.map((item) => {
const an = gewaehlt.has(item.id)
return (
<figure
key={item.id}
className={`group relative overflow-hidden rounded-lg border bg-flaeche
transition-colors ${an ? 'border-akzent' : 'border-rand'}`}
>
<button
onClick={() => umschalten(item.id)}
className="block w-full"
aria-pressed={an}
aria-label={`${item.path} auswählen`}
>
<div className="flex aspect-square items-center justify-center bg-grund">
{istBild(item.mime) ? (
<img
// Erst die Vorschau: 60 Vollbilder
// sind 7,2 MB, nur damit jemand
// sieht, WELCHE Bilder da sind.
// Fehlt sie (404), nimmt der
// Browser das Vollbild — deshalb
// braucht es dafuer weder eine
// Spalte noch eine Nachfrage.
src={vorschauUrl(item.path)}
onError={(e) => {
const bild = e.currentTarget
if (bild.dataset.gefallen) return
bild.dataset.gefallen = 'ja'
bild.src = item.url
}}
alt={item.path}
loading="lazy"
className="h-full w-full object-contain"
/>
) : (
<FileQuestion
size={28}
className="text-schrift-leise/50"
/>
)}
</div>
</button>
{an && (
<span className="pointer-events-none absolute top-2 left-2 rounded bg-akzent p-1 text-grund">
<Check size={12} />
</span>
)}
<figcaption className="border-t border-rand p-2">
<p
className="truncate font-mono text-xs text-schrift"
title={item.path}
>
{item.path}
</p>
<p className="mt-0.5 text-[11px] text-schrift-leise">
{bytes(item.size)} · {zeit(item.updated_at)}
</p>
<div className="mt-2 flex gap-1">
<button
onClick={() => void adresseKopieren(item)}
className="flex flex-1 items-center justify-center gap-1 rounded border
border-rand py-1 text-[11px] text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift"
>
{kopiert === String(item.id) ? (
<>
<Check size={11} className="text-gut" />
kopiert
</>
) : (
<>
<Copy size={11} />
Adresse
</>
)}
</button>
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift"
aria-label="In neuem Tab öffnen"
>
<ExternalLink size={11} />
</a>
<button
onClick={() => setLoeschen([item])}
className="rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-schlecht/15 hover:text-schlecht"
aria-label={`${item.path} löschen`}
>
<Trash2 size={11} />
</button>
</div>
</figcaption>
</figure>
)
})}
</div>
)}
{/* Blättern */}
{seiten > 1 && (
<div className="flex items-center justify-center gap-3">
<Knopf
disabled={seite === 0}
onClick={() => setSeite((s) => s - 1)}
kind={<ChevronLeft size={15} />}
/>
<span className="text-sm text-schrift-leise">
Seite {seite + 1} von {seiten}
</span>
<Knopf
disabled={seite + 1 >= seiten}
onClick={() => setSeite((s) => s + 1)}
kind={<ChevronRight size={15} />}
/>
</div>
)}
<Nachfrage
offen={loeschen !== null}
titel={loeschen?.length === 1 ? 'Datei löschen?' : `${loeschen?.length} Dateien löschen?`}
bestaetigung="Endgültig löschen"
wartet={loescht}
onNein={() => setLoeschen(null)}
onJa={() => void loeschenAusfuehren()}
text={
<>
<p className="mb-2">
Die Datei wird von der Platte entfernt. Es gibt keine Sicherung, aus
der sie zurückkommt.
</p>
<ul className="max-h-40 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-xs">
{loeschen?.slice(0, 12).map((i) => <li key={i.id}>{i.path}</li>)}
{loeschen && loeschen.length > 12 && (
<li className="text-schrift-leise">
und {loeschen.length - 12} weitere
</li>
)}
</ul>
</>
}
/>
</div>
)
}