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(null) const [seite, setSeite] = useState(0) const [art, setArt] = useState('') const [arten, setArten] = useState(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('galerie.sort', 'geaendert') const [richtung, setRichtung] = useMerker('galerie.dir', 'ab') const [daten, setDaten] = useState(null) const [laedt, setLaedt] = useState(true) const [gewaehlt, setGewaehlt] = useState>(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(null) const [verschieben, setVerschieben] = useState(null) const [umbenennen, setUmbenennen] = useState(null) const [ordnerUmbenennen, setOrdnerUmbenennen] = useState(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.
void ordnerLaden()} onUmbenennen={setOrdnerUmbenennen} onWahl={(o) => { setOrdner(o) setSeite(0) setGewaehlt(new Set()) }} />
{/* Nach Art filtern. Die Zahlen stehen dabei — ein Knopf "Video", hinter dem nichts liegt, kostet einen Klick, um das herauszufinden. */}
{( [ ['', '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 ( ) })}
{/* Kopfzeile: Suche, Auswahl, Löschen */}
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 && ( )}
{laedt ? ( ) : ( <> {gesamt} {gesamt === 1 ? 'Datei' : 'Dateien'} {sucheAktiv && ' gefunden'} )} {frisch.wartet && ( Neues da — laden } /> )} {items.length > 0 && ( )} {/* Erst anbieten, wenn es ueberhaupt mehr als eine Seite gibt -- sonst steht dort ein Knopf, der dasselbe tut wie der davor. */} {gesamt > items.length && ( void alleTrefferWaehlen()} kind={`Alle ${gesamt} wählen`} /> )} {gewaehlt.size > 0 && ( setVerschieben(items.filter((i) => gewaehlt.has(i.id)))} kind={ <> verschieben } /> )} {gewaehlt.size > 0 && ( setLoeschen({ // ALLE Gewaehlten, nicht nur die sichtbaren. ids: [...gewaehlt], namen: items.filter((i) => gewaehlt.has(i.id)), }) } kind={ <> {/* 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`} } /> )}
{ void laden() void ordnerLaden() void frisch.merken() }} /> {/* Das Raster */} {items.length === 0 && !laedt ? ( } text={ sucheAktiv ? `Nichts gefunden für "${sucheAktiv}"${ordner ? ` in ${ordner}/` : ''}.` : ordner ? `In ${ordner}/ liegt nichts.` : 'Noch keine Datei hochgeladen.' } /> ) : (
{items.map((item) => { const an = gewaehlt.has(item.id) return (
{/* 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. */} {/* 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. */}

{item.path}

{/* 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)} )}

) })}
)} {/* Blättern */} {seiten > 1 && (
setSeite((s) => s - 1)} kind={} /> Seite {seite + 1} von {seiten} = seiten} onClick={() => setSeite((s) => s + 1)} kind={} />
)}
{(() => { 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 ( 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 && ( setUmbenennen(null)} onFertig={() => { setUmbenennen(null) allesLaden() }} /> )} {ordnerUmbenennen !== null && ( 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 && ( setVerschieben(null)} onFertig={() => { setVerschieben(null) setGewaehlt(new Set()) void laden() void ordnerLaden() void artenLaden() }} /> )} 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. */}

Verschiebt in den Papierkorb — dort liegt alles noch 30 Tage und lässt sich zurückholen. Erst danach ist es endgültig weg.

    {loeschen?.namen.slice(0, 12).map((i) =>
  • {i.path}
  • )} {/* 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 && (
  • … und {loeschen.ids.length - loeschen.namen.slice(0, 12).length} weitere
  • )}
} />
) } /** 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 ( { // 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 . 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 ( ) }