Files
d4rk_media/server/ui/src/pages/Galerie.tsx
T
D4rkst3randClaude Opus 5 8a17b8b974 Loeschen loeschte nur die sichtbare Seite
Gemeldet: alle 3565 Bilder eines Ordners gewaehlt, auf "3565 loeschen"
gedrueckt -- weg waren 60. Sechzig ist die Seitengroesse, und darin steckt der
ganze Fehler:

    onClick={() => setLoeschen(items.filter((i) => gewaehlt.has(i.id)))}

`items` ist die OFFENE SEITE. Der Knopf zaehlte die Auswahl, geloescht wurde
die Schnittmenge mit dem gerade Sichtbaren. Die Absicht dahinter war gut --
der Dialog will die Pfade zeigen, und die kennt er nur fuer geladene Eintraege
-- aber das Ergebnis war eine Zahl, die etwas anderes versprach als sie tat.

Jetzt haelt der Auftrag beides getrennt: `ids` sind ALLE Gewaehlten, `namen`
nur die benennbaren. Der Dialog zeigt bis zu zwoelf Pfade und sagt "und N
weitere" -- ohne eine Vollstaendigkeit vorzutaeuschen, die er nicht hat.

IN HAEPPCHEN ZU 200. Der Dienst verschiebt jede Datei einzeln; 3565 in einer
Anfrage laufen minutenlang und fallen beim Proxy in die Zeitgrenze -- dann
waere die Haelfte weg und die Oberflaeche meldete einen Fehler. Nach jeder
Runde steht im Knopf, wie weit es ist: bei etwas, das eine Minute dauert, ist
Stillstand von Haengen nicht zu unterscheiden. Bricht eine Runde ab, wird
abgebrochen und die Zahl genannt, die schon durch ist.

UND DER WARNTEXT WAR FALSCH. Dort stand "Es gibt keine Sicherung, aus der sie
zurueckkommt" -- aus der Zeit vor dem Papierkorb. Nachgesehen: `inPapierkorb`
VERSCHIEBT die Datei und nimmt den Eintrag zurueck, wenn das Verschieben
scheitert; 30 Tage Aufbewahrung. Eine Warnung, die schlimmer klingt als die
Lage, haelt vom Aufraeumen ab und ist damit so schaedlich wie eine zu harmlose.
Der Knopf heisst jetzt "In den Papierkorb".

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

880 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Check,
ChevronLeft,
ChevronRight,
Copy,
ExternalLink,
FileQuestion,
ArrowDownUp,
FileText,
Film,
FolderInput,
Layers,
Music,
ImageOff,
Loader2,
Play,
RefreshCw,
Search,
Trash2,
X,
} from 'lucide-react'
import {
api,
vorschauUrl,
type Art,
type Arten,
type Folders,
type MediaItem,
type MediaPage,
type Richtung,
type Sortierung,
} from '../api'
import { run, useStore } from '../store'
import { bytes, dauerText, istBild, zeit } from '../format'
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
import { Ordner } from '../components/Ordner'
import { Hochladen } from '../components/Hochladen'
import { Ansehen } from '../components/Ansehen'
import { Verschieben } from '../components/Verschieben'
import { OrdnerUmbenennen, Umbenennen } from '../components/Umbenennen'
import { useAuffrischen } from '../auffrischen'
import { useMerker } from '../merker'
const SEITENGROESSEN = [30, 60, 120, 200] as const
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 [art, setArt] = useState<Art>('')
const [arten, setArten] = useState<Arten | null>(null)
// Ansichtssachen ueberleben das Neuladen -- wer 120 einstellt, meint das
// dauerhaft und nicht bis zum naechsten F5.
const [proSeite, setProSeite] = useMerker('galerie.proSeite', 60)
const [sortierung, setSortierung] = useMerker<Sortierung>('galerie.sort', 'geaendert')
const [richtung, setRichtung] = useMerker<Richtung>('galerie.dir', 'ab')
const [daten, setDaten] = useState<MediaPage | null>(null)
const [laedt, setLaedt] = useState(true)
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
/** Was geloescht werden soll.
*
* `ids` sind ALLE Gewaehlten, `namen` nur die, deren Pfad wir kennen --
* also die der offenen Seite. Vorher war das eine einzige Liste, und
* genau daran ist es gescheitert: der Knopf zaehlte die Auswahl, geloescht
* wurde die Schnittmenge mit der SICHTBAREN Seite. Bei 3565 gewaehlten
* Bildern verschwanden 60. */
const [loeschen, setLoeschen] = useState<{ ids: number[]; namen: MediaItem[] } | null>(null)
/** Wie weit das Loeschen ist -- bei tausenden Dateien dauert es. */
const [loeschFortschritt, setLoeschFortschritt] = useState(0)
const [loescht, setLoescht] = useState(false)
// Welche Datei gerade gross zu sehen ist -- als ID und nicht als Objekt:
// nach einem Neuladen der Liste zeigt sie sonst auf einen alten Datensatz.
const [ansehen, setAnsehen] = useState<number | null>(null)
const [verschieben, setVerschieben] = useState<MediaItem[] | null>(null)
const [umbenennen, setUmbenennen] = useState<MediaItem | null>(null)
const [ordnerUmbenennen, setOrdnerUmbenennen] = useState<string | null>(null)
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,
ordner,
art,
proSeite,
seite * proSeite,
sortierung,
richtung,
)
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, ordner, art, seite, proSeite, sortierung, richtung, 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.
// Die Zahlen an den Knoepfen haengen an Suche und Ordner, aber NICHT an
// der gewaehlten Art: sonst zeigte "Video (3)" nach dem Klick "Video (3)"
// und alle anderen 0.
const artenLaden = useCallback(async () => {
try {
setArten(await api.arten(sucheAktiv, ordner))
} catch {
// Die Zahlen sind Beiwerk. Faellt der Aufruf aus, bleiben die
// Knoepfe ohne Zahl statt die Seite mit einem Fehler zu fluten.
}
}, [sucheAktiv, ordner])
useEffect(() => {
void artenLaden()
}, [artenLaden])
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 allesLaden = useCallback(() => {
void laden()
void ordnerLaden()
void artenLaden()
}, [laden, ordnerLaden, artenLaden])
// Blockiert, solange eine Auswahl steht oder ein Bild offen ist: dann gibt
// es nur den Hinweis und keinen Neuaufbau unter den Haenden.
const frisch = useAuffrischen({
laden: allesLaden,
blockiert: gewaehlt.size > 0 || ansehen !== null || loeschen !== null,
})
const items = daten?.items ?? []
const gesamt = daten?.total ?? 0
const seiten = Math.max(1, Math.ceil(gesamt / proSeite))
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))
async function alleTrefferWaehlen() {
const antwort = await run(
() => api.medienIds(sucheAktiv, ordner, art),
(r) =>
r.gekappt
? `${r.ids.length} gewählt — mehr gibt der Dienst auf einmal nicht heraus.`
: `${r.ids.length} gewählt.`,
)
if (antwort) setGewaehlt(new Set(antwort.ids))
}
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)
setLoeschFortschritt(0)
// IN HAEPPCHEN, nicht in einem Zug.
//
// Der Dienst verschiebt jede Datei einzeln in den Papierkorb. Bei 3565
// Bildern ist das eine Anfrage, die Minuten laeuft -- und die faellt
// beim Proxy in die Zeitgrenze, lange bevor sie fertig ist. Dann waere
// die Haelfte geloescht und die Oberflaeche meldete einen Fehler.
//
// 200 je Runde: gross genug, dass es nicht an der Anzahl der Anfragen
// haengt, klein genug fuer eine Antwort in Sekunden. Und nach jeder
// Runde steht im Knopf, wie weit es ist -- bei etwas, das eine Minute
// dauert, ist Stillstand nicht von Haengen zu unterscheiden.
const { note } = useStore.getState()
const PORTION = 200
const alle = loeschen.ids
let geloescht = 0
let abbruch: string | null = null
for (let i = 0; i < alle.length; i += PORTION) {
const teil = alle.slice(i, i + PORTION)
try {
const r = teil.length === 1
? await api.deleteMedia(teil[0]!).then(() => ({ count: 1 }))
: await api.deleteMediaBulk(teil)
geloescht += r.count
} catch (e) {
// NICHT WEITERMACHEN. Wenn eine Runde scheitert, scheitern die
// naechsten vermutlich auch, und tausend Fehlermeldungen sagen
// weniger als eine mit der Zahl, die schon durch ist.
abbruch = e instanceof Error ? e.message : 'Unbekannt'
break
}
setLoeschFortschritt(geloescht)
}
if (abbruch) {
note('fehler', `${geloescht} von ${alle.length} gelöscht, dann brach es ab: ${abbruch}`)
} else if (geloescht === alle.length) {
note('ok', `${geloescht} ${geloescht === 1 ? 'Datei' : 'Dateien'} in den Papierkorb verschoben.`)
} else {
// Der Dienst ueberspringt, was er nicht findet. Diese Luecke zu
// verschweigen waere genau die Sorte Rueckmeldung, die hier nicht
// vorkommt.
note('hinweis', `${geloescht} von ${alle.length} gelöscht — der Rest war schon weg.`)
}
const ergebnis = !abbruch
setLoescht(false)
setLoeschen(null)
if (ergebnis) {
setGewaehlt(new Set())
void laden()
void ordnerLaden()
void frisch.merken()
}
}
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 (
// Ab 2xl (1536 px) rueckt die Ordnerspalte nach LINKS AUS dem
// Hauptbereich heraus: 15rem Spalte + 1rem Abstand = die 16rem, um die
// hier nach links gezogen wird. Damit steht das Raster auf genau
// derselben Breite wie jede andere Seite.
//
// Darunter stapelt es wie bisher — auf einem 1280er Schirm waere kein
// Rand da, in den etwas ruecken koennte.
<div className="flex flex-col gap-4 lg:flex-row 2xl:-ml-64">
<Ordner
daten={ordnerDaten}
gewaehlt={ordner}
onAngelegt={() => void ordnerLaden()}
onUmbenennen={setOrdnerUmbenennen}
onWahl={(o) => {
setOrdner(o)
setSeite(0)
setGewaehlt(new Set())
}}
/>
<div className="flex min-w-0 flex-1 flex-col gap-4">
{/* Nach Art filtern. Die Zahlen stehen dabei — ein Knopf "Video",
hinter dem nichts liegt, kostet einen Klick, um das
herauszufinden. */}
<div className="flex flex-wrap items-center gap-1.5">
{(
[
['', 'Alles', Layers],
['bild', 'Bilder', ImageOff],
['video', 'Video', Film],
['ton', 'Ton', Music],
['dokument', 'Dokumente', FileText],
['andere', 'Anderes', FileQuestion],
] as const
).map(([wert, text, Symbol]) => {
const n = arten?.[wert || 'alle']
const an = art === wert
// Leere Arten bleiben stehen statt zu verschwinden: eine
// Leiste, die ihre Knöpfe wechselt, trifft man nicht.
return (
<button
key={wert}
onClick={() => {
setArt(wert as Art)
setSeite(0)
setGewaehlt(new Set())
}}
disabled={n === 0 && wert !== ''}
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs
transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
an
? 'border-akzent bg-akzent/15 text-schrift'
: 'border-rand text-schrift-leise hover:text-schrift'
}`}
>
<Symbol size={13} />
{text}
{n !== undefined && (
<span className="tabular-nums opacity-70">{n}</span>
)}
</button>
)
})}
</div>
{/* 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>
{frisch.wartet && (
<Knopf
art="haupt"
onClick={frisch.jetztLaden}
kind={
<>
<RefreshCw size={14} />
Neues da laden
</>
}
/>
)}
<label
className="flex cursor-pointer items-center gap-1.5 text-xs text-schrift-leise"
title="Alle fünf Sekunden nachsehen, ob sich etwas geändert hat"
>
<input
type="checkbox"
checked={frisch.an}
onChange={(e) => frisch.setAn(e.target.checked)}
className="accent-akzent"
/>
automatisch
</label>
<label className="flex items-center gap-1.5 text-xs text-schrift-leise">
<ArrowDownUp size={13} />
<select
value={sortierung}
onChange={(e) => {
setSortierung(e.target.value as Sortierung)
setSeite(0)
}}
className="rounded-md border border-rand bg-flaeche px-2 py-1.5 text-sm
text-schrift focus:border-akzent focus:outline-none"
>
<option value="geaendert">zuletzt geändert</option>
<option value="angelegt">zuerst hochgeladen</option>
<option value="pfad">Pfad</option>
<option value="groesse">Größe</option>
{/* Gezählt wurde längst — ordnen konnte man danach
nicht, und damit beantwortete die Zahl die Frage
„was wird eigentlich gebraucht" nur für die zehn
im Speicherbericht. */}
<option value="abrufe">Abrufe</option>
<option value="geholt">zuletzt geholt</option>
</select>
<select
value={proSeite}
onChange={(e) => {
setProSeite(Number(e.target.value))
setSeite(0)
}}
title="wie viele Kacheln je Seite"
className="rounded-md border border-rand bg-flaeche px-2 py-1.5 text-sm
text-schrift focus:border-akzent focus:outline-none"
>
{SEITENGROESSEN.map((n) => (
<option key={n} value={n}>
{n} / Seite
</option>
))}
</select>
<button
onClick={() => {
setRichtung(richtung === 'ab' ? 'auf' : 'ab')
setSeite(0)
}}
className="rounded-md border border-rand px-2 py-1.5 text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift"
title={richtung === 'ab' ? 'absteigend' : 'aufsteigend'}
>
{richtung === 'ab' ? '↓' : '↑'}
</button>
</label>
{items.length > 0 && (
<Knopf
onClick={alleUmschalten}
kind={alleGewaehlt ? 'Auswahl aufheben' : 'Seite wählen'}
/>
)}
{/* Erst anbieten, wenn es ueberhaupt mehr als eine Seite gibt --
sonst steht dort ein Knopf, der dasselbe tut wie der davor. */}
{gesamt > items.length && (
<Knopf
onClick={() => void alleTrefferWaehlen()}
kind={`Alle ${gesamt} wählen`}
/>
)}
{gewaehlt.size > 0 && (
<Knopf
onClick={() => setVerschieben(items.filter((i) => gewaehlt.has(i.id)))}
kind={
<>
<FolderInput size={15} />
verschieben
</>
}
/>
)}
{gewaehlt.size > 0 && (
<Knopf
art="gefahr"
onClick={() =>
setLoeschen({
// ALLE Gewaehlten, nicht nur die sichtbaren.
ids: [...gewaehlt],
namen: items.filter((i) => gewaehlt.has(i.id)),
})
}
kind={
<>
<Trash2 size={15} />
{/* WAEHREND ES LAEUFT die Zahl, die schon
durch ist. Bei tausenden Dateien dauert es
eine Minute, und Stillstand ist von Haengen
nicht zu unterscheiden. */}
{loescht && loeschFortschritt > 0
? `${loeschFortschritt} von ${gewaehlt.size} …`
: `${gewaehlt.size} löschen`}
</>
}
/>
)}
</div>
<Hochladen
ordner={ordner}
ordnerDaten={ordnerDaten}
fertig={() => {
void laden()
void ordnerLaden()
void frisch.merken()
}}
/>
{/* Das Raster */}
{items.length === 0 && !laedt ? (
<Leer
kind={<ImageOff size={28} className="text-schrift-leise/50" />}
text={
sucheAktiv
? `Nichts gefunden für "${sucheAktiv}"${ordner ? ` in ${ordner}/` : ''}.`
: ordner
? `In ${ordner}/ liegt nichts.`
: '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-all hover:-translate-y-0.5 hover:shadow-lg
hover:shadow-black/30 ${
an
? 'border-akzent ring-1 ring-akzent/40'
: 'border-rand hover:border-schrift-leise/40'
}`}
>
{/* Klick auf die Kachel ÖFFNET, Klick auf das
Kästchen WÄHLT AUS. Vorher tat der Klick
beides nicht gleichzeitig — er wählte nur
aus, und zum Ansehen musste man einen
neuen Tab aufmachen. */}
<button
onClick={() => setAnsehen(item.id)}
className="block w-full"
aria-label={`${item.path} ansehen`}
>
<div className="relative flex aspect-square items-center justify-center bg-grund">
<Vorschau item={item} />
{/* Das Abspielzeichen liegt ÜBER dem
Standbild. Ohne es sieht ein Video
aus wie ein Bild, und man klickt es
in der Erwartung an, ein Bild zu
sehen. */}
{(item.mime.startsWith('video') ||
item.mime.startsWith('audio')) && (
<span
className="pointer-events-none absolute inset-0 flex
items-center justify-center"
>
<span className="rounded-full bg-black/55 p-2">
<Play
size={16}
className="translate-x-[1px] fill-white text-white"
/>
</span>
</span>
)}
{item.dauer !== null && item.dauer > 0 && (
<span
className="pointer-events-none absolute right-1.5 bottom-1.5
rounded bg-black/70 px-1.5 py-0.5 text-[11px]
tabular-nums text-white"
>
{dauerText(item.dauer)}
</span>
)}
</div>
</button>
{/* Immer sichtbar, wenn gewählt; sonst erst
beim Darüberfahren. Ein Kästchen, das man
erst suchen muss, ist keines — eines, das
bei 60 Kacheln immer leuchtet, ist Lärm. */}
<button
onClick={(e) => {
e.stopPropagation()
umschalten(item.id)
}}
aria-pressed={an}
aria-label={`${item.path} auswählen`}
className={`absolute top-2 left-2 rounded border p-1 transition-opacity ${
an
? 'border-akzent bg-akzent text-grund opacity-100'
: 'border-rand bg-grund/80 text-schrift-leise opacity-0 group-hover:opacity-100 hover:text-schrift focus-visible:opacity-100'
}`}
>
<Check size={12} />
</button>
<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">
{/* Wonach sortiert wird, STEHT AUCH DA.
Nach einer Zahl zu ordnen, die man
nirgends sieht, ist eine Reihenfolge
ohne Begründung — man sieht nur, dass
sich etwas geändert hat. */}
{sortierung === 'abrufe' || sortierung === 'geholt' ? (
<>
{item.abrufe}× geholt ·{' '}
{item.zuletzt_abgerufen
? zeit(item.zuletzt_abgerufen)
: 'nie'}
</>
) : (
<>
{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({ ids: [item.id], namen: [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>
)}
</div>
{(() => {
if (ansehen === null) return null
const i = items.findIndex((x) => x.id === ansehen)
// Die Datei kann zwischenzeitlich verschwunden sein -- etwa
// weil daneben geloescht wurde. Dann schliesst sich der
// Betrachter, statt auf ein Loch zu zeigen.
if (i === -1) return null
const item = items[i]!
return (
<Ansehen
item={item}
naechstes={items[i + 1]}
hatVorher={i > 0}
hatNachher={i < items.length - 1}
onVorher={() => setAnsehen(items[i - 1]!.id)}
onNachher={() => setAnsehen(items[i + 1]!.id)}
onSchliessen={() => setAnsehen(null)}
onLoeschen={() => {
setAnsehen(null)
setLoeschen({ ids: [item.id], namen: [item] })
}}
onUmbenennen={() => {
setAnsehen(null)
setUmbenennen(item)
}}
/>
)
})()}
{umbenennen && (
<Umbenennen
datei={umbenennen}
onAbbruch={() => setUmbenennen(null)}
onFertig={() => {
setUmbenennen(null)
allesLaden()
}}
/>
)}
{ordnerUmbenennen !== null && (
<OrdnerUmbenennen
pfad={ordnerUmbenennen}
dateien={
ordnerDaten?.items.find((o) => o.folder === ordnerUmbenennen)?.files ?? 0
}
onAbbruch={() => setOrdnerUmbenennen(null)}
onFertig={(neu) => {
setOrdnerUmbenennen(null)
// Steht die Ansicht IM umbenannten Ordner, muss sie
// mitwandern -- sonst zeigt sie auf einen Pfad, den es
// nicht mehr gibt, und der Inhalt ist leer.
if (ordner === ordnerUmbenennen || ordner.startsWith(ordnerUmbenennen + '/')) {
setOrdner(neu + ordner.slice(ordnerUmbenennen.length))
}
allesLaden()
}}
/>
)}
{verschieben && (
<Verschieben
dateien={verschieben}
ordner={ordnerDaten}
onAbbruch={() => setVerschieben(null)}
onFertig={() => {
setVerschieben(null)
setGewaehlt(new Set())
void laden()
void ordnerLaden()
void artenLaden()
}}
/>
)}
<Nachfrage
offen={loeschen !== null}
titel={
loeschen?.ids.length === 1
? 'Datei löschen?'
: `${loeschen?.ids.length ?? 0} Dateien löschen?`
}
bestaetigung="In den Papierkorb"
wartet={loescht}
onNein={() => setLoeschen(null)}
onJa={() => void loeschenAusfuehren()}
text={
<>
{/* DER SATZ HIER WAR FALSCH und stand aus der Zeit
vor dem Papierkorb: "Es gibt keine Sicherung, aus
der sie zurueckkommt." Es gibt eine -- die Datei
wird verschoben, nicht geloescht, und liegt 30 Tage
im Papierkorb. Eine Warnung, die schlimmer klingt
als die Lage, haelt vom Aufraeumen ab und ist damit
genauso schaedlich wie eine, die zu harmlos klingt. */}
<p className="mb-2">
Verschiebt in den <b>Papierkorb</b> dort liegt alles noch 30 Tage und
lässt sich zurückholen. Erst danach ist es endgültig weg.
</p>
<ul className="max-h-40 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-xs">
{loeschen?.namen.slice(0, 12).map((i) => <li key={i.id}>{i.path}</li>)}
{/* Benennen laesst sich nur, was auf der offenen
Seite liegt -- der Rest ist gewaehlt, aber
nicht geladen. Das steht hier so, statt eine
Vollstaendigkeit vorzutaeuschen. */}
{loeschen && loeschen.ids.length > loeschen.namen.slice(0, 12).length && (
<li className="text-schrift-leise">
und {loeschen.ids.length - loeschen.namen.slice(0, 12).length} weitere
</li>
)}
</ul>
</>
}
/>
</div>
)
}
/** Was auf der Kachel steht.
*
* ERST DIE VORSCHAU, immer: 60 Vollbilder sind 7,2 MB, nur damit jemand sieht,
* WELCHE Dateien da sind. Video und Ton haben seit ffmpeg ebenfalls eine —
* ein Standbild aus dem Clip, bei einer MP3 das eingebettete Titelbild.
*
* FEHLT SIE, wird nicht geraten. Bei einem Bild nimmt der Browser über
* `onError` das Vollbild; bei allem anderen kommt ein Zeichen, das die ART
* benennt. Vorher stand dort für jede Nicht-Bild-Datei ein Fragezeichen —
* also ausgerechnet das Zeichen für „unbekannt" auf einer völlig gesunden
* MP4. */
function Vorschau({ item }: { item: MediaItem }) {
const [gescheitert, setGescheitert] = useState(false)
const bewegt = item.mime.startsWith('video') || item.mime.startsWith('audio')
// Sobald die Datei wechselt, ist ein früherer Fehlschlag hinfällig.
useEffect(() => setGescheitert(false), [item.id])
if (!gescheitert) {
return (
<img
src={vorschauUrl(item.path)}
onError={(e) => {
// Bei einem Bild lohnt der zweite Versuch mit dem Vollbild:
// die Vorschau ist abgeleitet und kann fehlen, das Bild
// selbst liegt da. Bei Video und Ton wäre derselbe Griff
// grober Unfug — er lüde den ganzen Clip in ein <img>.
const bild = e.currentTarget
if (istBild(item.mime) && !bild.dataset.gefallen) {
bild.dataset.gefallen = 'ja'
bild.src = item.url
return
}
setGescheitert(true)
}}
alt={item.path}
loading="lazy"
className="h-full w-full object-contain"
/>
)
}
const Zeichen = item.mime.startsWith('video')
? Film
: item.mime.startsWith('audio')
? Music
: item.mime === 'application/pdf' ||
item.mime.startsWith('text/') ||
item.mime.startsWith('application/json')
? FileText
: istBild(item.mime)
? ImageOff
: FileQuestion
return (
<Zeichen
size={28}
className={bewegt ? 'text-schrift-leise/30' : 'text-schrift-leise/50'}
/>
)
}