feat: Galerie fuer 900 Dateien, und ein Finder fuer Verwaiste
SORTIERUNG. Nach Pfad, Groesse, Anlage- oder Aenderungsdatum, auf- und absteigend. Bisher gab es genau eine Reihenfolge (zuletzt geaendert), was bei 228 Dateien schon nicht mehr reicht. Die Sortierung kommt vom Aufrufer und geht in SQL, also wird sie NIEMALS durchgereicht, sondern gegen eine feste Liste getauscht. Nachgemessen mit "sort=; DROP TABLE media--": faellt auf den Standard zurueck, Datenbank unveraendert. Nach dem Pfad wird ohne Ruecksicht auf Gross- und Kleinschreibung sortiert, sonst stuende "Adder" vor "adder" und dazwischen nichts. ALLE TREFFER WAEHLEN. GET /media/ids liefert nur die IDs zur aktuellen Auswahl -- ohne das braucht das Leeren eines Ordners mit 900 Bildern fuenfzehn Seiten Klicken. Gedeckelt auf 5000, und wer daran stoesst, bekommt es GESAGT (gekappt: true) statt still eine halbe Auswahl. VERWAISTE. Ein Knopf zaehlt drei Arten von Abweichung: Dateien ohne Datensatz, Datensaetze ohne Datei, Vorschauen ohne Bild. ERST ZAEHLEN, DANN FRAGEN -- die Suche veraendert nichts. Und das Aufraeumen geht in ZWEI Richtungen, wobei die schonende die Voreinstellung ist: "aufnehmen" gibt Dateien ohne Datensatz einen, sie sind ja da und werden ausgeliefert -- sie fehlen nur in der Liste. Erst "aufraeumen" entfernt etwas, und auch dann nur Verweise auf Dateien, die es nicht mehr gibt, und abgeleitete Vorschauen. Beides landet im Verlauf. Am laufenden System durchgespielt: Datei am Dienst vorbei ins Volume gelegt -> gefunden; aufgenommen -> steht in der Galerie; Datei hinter dem Ruecken geloescht -> Datensatz ohne Datei gefunden; aufgeraeumt -> weg. Verlauf hat beide Schritte mit user:admin. (Der erste Versuch dieses Tests hat gelogen: Git Bash schreibt einen absoluten Pfad in `docker exec` zu einem Windows-Pfad um -- rm suchte nach "C:/Program Files/Git/data/...". `rm -f` schweigt dabei. Mit `sh -c` drumherum bleibt der Pfad heil.) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+30
-2
@@ -159,6 +159,15 @@ export type Folders = {
|
||||
/** Der billige Fuehler fuer die automatische Auffrischung. */
|
||||
export type Stand = { medien: number; neuest: number; ereignis: number }
|
||||
|
||||
export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse'
|
||||
export type Richtung = 'auf' | 'ab'
|
||||
|
||||
export type Verwaiste = {
|
||||
dateiOhneDatensatz: { anzahl: number; beispiele: string[] }
|
||||
datensatzOhneDatei: { anzahl: number; beispiele: string[] }
|
||||
vorschauOhneBild: { anzahl: number; beispiele: string[] }
|
||||
}
|
||||
|
||||
export type Einstellungen = {
|
||||
discord: {
|
||||
clientId: string
|
||||
@@ -241,10 +250,23 @@ export const api = {
|
||||
changePassword: (current: string, next: string) =>
|
||||
post<{ ok: true }>('/api/dash/auth/password', { current, next }),
|
||||
|
||||
media: (query: string, folder: string, limit: number, offset: number) =>
|
||||
media: (
|
||||
query: string,
|
||||
folder: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
sort: Sortierung = 'geaendert',
|
||||
dir: Richtung = 'ab',
|
||||
) =>
|
||||
get<MediaPage>(
|
||||
`/api/dash/media?query=${encodeURIComponent(query)}` +
|
||||
`&folder=${encodeURIComponent(folder)}&limit=${limit}&offset=${offset}`,
|
||||
`&folder=${encodeURIComponent(folder)}&limit=${limit}&offset=${offset}` +
|
||||
`&sort=${sort}&dir=${dir}`,
|
||||
),
|
||||
/** Nur die IDs — fuer "alle Treffer waehlen", ohne 15 Seiten zu blaettern. */
|
||||
medienIds: (query: string, folder: string) =>
|
||||
get<{ ids: number[]; gekappt: boolean }>(
|
||||
`/api/dash/media/ids?query=${encodeURIComponent(query)}&folder=${encodeURIComponent(folder)}`,
|
||||
),
|
||||
folders: () => get<Folders>('/api/dash/folders'),
|
||||
deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`),
|
||||
@@ -271,6 +293,12 @@ export const api = {
|
||||
stand: () => get<Stand>('/api/dash/stand'),
|
||||
stats: () => get<Stats>('/api/dash/stats'),
|
||||
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
|
||||
verwaiste: () => get<Verwaiste>('/api/dash/maintenance/orphans'),
|
||||
verwaisteBehandeln: (aktion: 'aufnehmen' | 'aufraeumen') =>
|
||||
post<{ aktion: string; aufgenommen?: number; datensaetze?: number; vorschauen?: number }>(
|
||||
'/api/dash/maintenance/orphans',
|
||||
{ aktion },
|
||||
),
|
||||
|
||||
tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'),
|
||||
createToken: (name: string, prefix: string, canDelete: boolean) =>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileQuestion,
|
||||
ArrowDownUp,
|
||||
ImageOff,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { api, vorschauUrl, type Folders, type MediaItem, type MediaPage } from '../api'
|
||||
import { api, vorschauUrl, type Folders, type MediaItem, type MediaPage, type Richtung, type Sortierung } from '../api'
|
||||
import { run, useStore } from '../store'
|
||||
import { bytes, istBild, zeit } from '../format'
|
||||
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
|
||||
@@ -30,6 +31,8 @@ export function Galerie() {
|
||||
const [ordner, setOrdner] = useState('')
|
||||
const [ordnerDaten, setOrdnerDaten] = useState<Folders | null>(null)
|
||||
const [seite, setSeite] = useState(0)
|
||||
const [sortierung, setSortierung] = useState<Sortierung>('geaendert')
|
||||
const [richtung, setRichtung] = useState<Richtung>('ab')
|
||||
const [daten, setDaten] = useState<MediaPage | null>(null)
|
||||
const [laedt, setLaedt] = useState(true)
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||
@@ -61,7 +64,14 @@ export function Galerie() {
|
||||
const meine = ++lauf.current
|
||||
setLaedt(true)
|
||||
try {
|
||||
const seiteDaten = await api.media(sucheAktiv, ordner, PRO_SEITE, seite * PRO_SEITE)
|
||||
const seiteDaten = await api.media(
|
||||
sucheAktiv,
|
||||
ordner,
|
||||
PRO_SEITE,
|
||||
seite * PRO_SEITE,
|
||||
sortierung,
|
||||
richtung,
|
||||
)
|
||||
if (meine !== lauf.current) return
|
||||
setDaten(seiteDaten)
|
||||
} catch (err) {
|
||||
@@ -70,7 +80,7 @@ export function Galerie() {
|
||||
} finally {
|
||||
if (meine === lauf.current) setLaedt(false)
|
||||
}
|
||||
}, [sucheAktiv, ordner, seite, note])
|
||||
}, [sucheAktiv, ordner, seite, sortierung, richtung, note])
|
||||
|
||||
useEffect(() => {
|
||||
void laden()
|
||||
@@ -119,6 +129,17 @@ export function Galerie() {
|
||||
|
||||
const alleGewaehlt = items.length > 0 && items.every((i) => gewaehlt.has(i.id))
|
||||
|
||||
async function alleTrefferWaehlen() {
|
||||
const antwort = await run(
|
||||
() => api.medienIds(sucheAktiv, ordner),
|
||||
(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)
|
||||
@@ -240,6 +261,35 @@ export function Galerie() {
|
||||
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>
|
||||
</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}
|
||||
@@ -247,6 +297,15 @@ export function Galerie() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
art="gefahr"
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, Copy, Folder, HardDrive, Images, KeyRound, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Folder,
|
||||
HardDrive,
|
||||
Images,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
SearchCheck,
|
||||
} from 'lucide-react'
|
||||
import { api, type Stats } from '../api'
|
||||
import { run, useStore } from '../store'
|
||||
import { bytes } from '../format'
|
||||
import { Karte, Knopf, Leer, useKopieren } from '../components/Bausteine'
|
||||
import { Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
|
||||
import { type Verwaiste } from '../api'
|
||||
|
||||
export function Speicher() {
|
||||
const note = useStore((s) => s.note)
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [laedt, setLaedt] = useState(true)
|
||||
const [zieht, setZieht] = useState(false)
|
||||
const [verwaiste, setVerwaiste] = useState<Verwaiste | null>(null)
|
||||
const [sucht, setSucht] = useState(false)
|
||||
const [handelt, setHandelt] = useState(false)
|
||||
const [fragt, setFragt] = useState<'aufnehmen' | 'aufraeumen' | null>(null)
|
||||
const { kopiert, kopieren } = useKopieren()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,8 +113,119 @@ export function Speicher() {
|
||||
hochgeladen wurde, hat noch keine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-rand pt-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Knopf
|
||||
wartet={sucht}
|
||||
onClick={async () => {
|
||||
setSucht(true)
|
||||
const v = await run(
|
||||
() => api.verwaiste(),
|
||||
(r) =>
|
||||
r.dateiOhneDatensatz.anzahl +
|
||||
r.datensatzOhneDatei.anzahl +
|
||||
r.vorschauOhneBild.anzahl ===
|
||||
0
|
||||
? 'Alles passt zusammen.'
|
||||
: 'Abweichungen gefunden — siehe unten.',
|
||||
)
|
||||
setSucht(false)
|
||||
if (v) setVerwaiste(v)
|
||||
}}
|
||||
kind={
|
||||
<>
|
||||
<SearchCheck size={15} />
|
||||
Verwaiste suchen
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-schrift-leise">
|
||||
Dateien ohne Datensatz, Datensätze ohne Datei, Vorschauen ohne
|
||||
Bild. Sucht nur — verändert nichts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{verwaiste && (
|
||||
<div className="mt-3 flex flex-col gap-2 text-sm">
|
||||
<Befund
|
||||
titel="Dateien ohne Datensatz"
|
||||
daten={verwaiste.dateiOhneDatensatz}
|
||||
hinweis="Liegen da und werden ausgeliefert, fehlen aber in der Liste."
|
||||
knopf={
|
||||
verwaiste.dateiOhneDatensatz.anzahl > 0 ? (
|
||||
<Knopf
|
||||
art="haupt"
|
||||
wartet={handelt}
|
||||
onClick={() => setFragt('aufnehmen')}
|
||||
kind="In die Liste aufnehmen"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Befund
|
||||
titel="Datensätze ohne Datei"
|
||||
daten={verwaiste.datensatzOhneDatei}
|
||||
hinweis="Zeigen ins Leere — die Galerie zeigt ein kaputtes Bild."
|
||||
/>
|
||||
<Befund
|
||||
titel="Vorschauen ohne Bild"
|
||||
daten={verwaiste.vorschauOhneBild}
|
||||
hinweis="Belegen Platz, sonst harmlos."
|
||||
/>
|
||||
{(verwaiste.datensatzOhneDatei.anzahl > 0 ||
|
||||
verwaiste.vorschauOhneBild.anzahl > 0) && (
|
||||
<div>
|
||||
<Knopf
|
||||
art="gefahr"
|
||||
wartet={handelt}
|
||||
onClick={() => setFragt('aufraeumen')}
|
||||
kind="Leere Datensätze und Vorschauen entfernen"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<Nachfrage
|
||||
offen={fragt !== null}
|
||||
titel={fragt === 'aufnehmen' ? 'In die Liste aufnehmen?' : 'Aufräumen?'}
|
||||
bestaetigung={fragt === 'aufnehmen' ? 'Aufnehmen' : 'Entfernen'}
|
||||
wartet={handelt}
|
||||
onNein={() => setFragt(null)}
|
||||
onJa={async () => {
|
||||
if (!fragt) return
|
||||
setHandelt(true)
|
||||
await run(
|
||||
() => api.verwaisteBehandeln(fragt),
|
||||
(r) =>
|
||||
fragt === 'aufnehmen'
|
||||
? `${r.aufgenommen} Dateien in die Liste aufgenommen.`
|
||||
: `${r.datensaetze} Datensätze und ${r.vorschauen} Vorschauen entfernt.`,
|
||||
)
|
||||
setHandelt(false)
|
||||
setFragt(null)
|
||||
const v = await run(() => api.verwaiste(), 'Neu gezählt.')
|
||||
if (v) setVerwaiste(v)
|
||||
}}
|
||||
text={
|
||||
fragt === 'aufnehmen' ? (
|
||||
<p>
|
||||
Die gefundenen Dateien bekommen einen Datensatz und erscheinen in der
|
||||
Galerie. Es wird nichts gelöscht und nichts überschrieben.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Datensätze, deren Datei fehlt, werden entfernt — die Dateien sind
|
||||
ohnehin weg. Verwaiste Vorschauen werden gelöscht; sie lassen sich
|
||||
jederzeit neu rechnen.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Karte titel="Größte Ordner">
|
||||
{stats.folders.length === 0 ? (
|
||||
<Leer text="Noch nichts abgelegt." />
|
||||
@@ -142,6 +267,41 @@ export function Speicher() {
|
||||
)
|
||||
}
|
||||
|
||||
function Befund({
|
||||
titel,
|
||||
daten,
|
||||
hinweis,
|
||||
knopf,
|
||||
}: {
|
||||
titel: string
|
||||
daten: { anzahl: number; beispiele: string[] }
|
||||
hinweis: string
|
||||
knopf?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-rand bg-grund p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={daten.anzahl > 0 ? 'font-medium text-warn' : 'text-schrift-leise'}>
|
||||
{daten.anzahl}
|
||||
</span>
|
||||
<span className="text-sm">{titel}</span>
|
||||
<span className="flex-1 text-xs text-schrift-leise">{hinweis}</span>
|
||||
{knopf}
|
||||
</div>
|
||||
{daten.beispiele.length > 0 && (
|
||||
<ul className="mt-2 max-h-28 overflow-auto font-mono text-[11px] text-schrift-leise">
|
||||
{daten.beispiele.map((b) => (
|
||||
<li key={b}>{b}</li>
|
||||
))}
|
||||
{daten.anzahl > daten.beispiele.length && (
|
||||
<li>… und {daten.anzahl - daten.beispiele.length} weitere</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Zahl({ kind, was, wert }: { kind: React.ReactNode; was: string; wert: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-rand bg-flaeche p-4">
|
||||
|
||||
Reference in New Issue
Block a user