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>
880 lines
39 KiB
TypeScript
880 lines
39 KiB
TypeScript
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'}
|
||
/>
|
||
)
|
||
}
|