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>
351 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|