VERSCHIEBEN: derselbe Griff wie beim Loeschen, dieselbe Wirkung.
onClick={() => setVerschieben(items.filter((i) => gewaehlt.has(i.id)))}
`items` ist die offene Seite; von 3565 gewaehlten Bildern wanderten 60. Ich
hatte gestern nur das Loeschen repariert und diese Stelle nicht gesucht --
zwei Knoepfe mit demselben Muster, und ich habe einen davon uebersehen.
Jetzt haelt der Auftrag auch hier `ids` (alle) und `namen` (die benennbaren)
getrennt, und verschoben wird in Haeppchen zu 200 mit Fortschritt im Knopf.
Was nicht ging, wird benannt statt verschwiegen.
ORDNER ENTFERNEN: die Route gab es (`DELETE /folders/:pfad`), die
Schnittstelle auch (`ordnerEntfernen` in api.ts) -- nur wurde sie NIRGENDS
aufgerufen. Eine Funktion ohne Knopf ist fuer den, der sie sucht, nicht
vorhanden; dieselbe Lehre steht schon ueber "Neuer Ordner" in derselben Datei.
Der Knopf sitzt jetzt neben dem Umbenennen, erst beim Darueberfahren sichtbar.
Die REGEL bleibt beim Dienst: er lehnt einen Ordner mit Inhalt ab und sagt,
wieviele Dateien darin liegen. Sie in der Oberflaeche zu wiederholen hiesse,
sie zweimal zu pflegen -- und die zweite Fassung ist irgendwann die falsche.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
7.8 KiB
TypeScript
191 lines
7.8 KiB
TypeScript
import { useState } from 'react'
|
|
import { AlertTriangle, FolderInput, Plus } from 'lucide-react'
|
|
import { api, type Folders, type MediaItem } from '../api'
|
|
import { run, useStore } from '../store'
|
|
import { Feld, Knopf } from './Bausteine'
|
|
|
|
/** Dateien in einen anderen Ordner legen.
|
|
*
|
|
* DIE WARNUNG STEHT OBEN UND NICHT IM KLEINGEDRUCKTEN. Der Pfad IST die
|
|
* öffentliche Adresse: wer ein Bild verschiebt, macht jede Stelle ungültig,
|
|
* die die alte URL benutzt — im Handy, in einer Vorlage, in einem
|
|
* Zwischenspeicher. Das ist kein Fehler, sondern der Preis vorhersagbarer
|
|
* Adressen; nur muss man es vorher wissen und nicht hinterher suchen. */
|
|
export function Verschieben({
|
|
ids,
|
|
namen,
|
|
ordner,
|
|
onFertig,
|
|
onAbbruch,
|
|
}: {
|
|
/** ALLE Gewaehlten. */
|
|
ids: number[]
|
|
/** Nur die, deren Pfad bekannt ist -- die der offenen Seite. Fuer die
|
|
* Anzeige, nicht fuer die Handlung. */
|
|
namen: MediaItem[]
|
|
ordner: Folders | null
|
|
onFertig: () => void
|
|
onAbbruch: () => void
|
|
}) {
|
|
const [ziel, setZiel] = useState('')
|
|
const [neuerOrdner, setNeuerOrdner] = useState('')
|
|
const [laeuft, setLaeuft] = useState(false)
|
|
const [fortschritt, setFortschritt] = useState(0)
|
|
|
|
const beispiel = namen[0]
|
|
const name = beispiel?.path.split('/').pop() ?? ''
|
|
const neuerPfad = ziel ? `${ziel}/${name}` : name
|
|
|
|
async function verschieben() {
|
|
setLaeuft(true)
|
|
const { note } = useStore.getState()
|
|
|
|
// IN HAEPPCHEN, wie beim Loeschen: der Dienst verschiebt jede Datei
|
|
// einzeln, und tausende in einer Anfrage laufen in die Zeitgrenze des
|
|
// Proxys. 200 je Runde, und der Knopf sagt, wie weit es ist.
|
|
const PORTION = 200
|
|
let bewegt = 0
|
|
let daneben: string[] = []
|
|
let abbruch: string | null = null
|
|
|
|
for (let i = 0; i < ids.length; i += PORTION) {
|
|
try {
|
|
const x = await api.verschieben(ids.slice(i, i + PORTION), ziel)
|
|
bewegt += x.verschoben
|
|
daneben.push(
|
|
...x.ergebnisse.filter((e) => !e.ok).map((e) => `${e.path} (${e.error})`),
|
|
)
|
|
} catch (e) {
|
|
abbruch = e instanceof Error ? e.message : 'Unbekannt'
|
|
break
|
|
}
|
|
setFortschritt(bewegt)
|
|
}
|
|
|
|
setLaeuft(false)
|
|
if (abbruch) {
|
|
note('fehler', `${bewegt} von ${ids.length} verschoben, dann brach es ab: ${abbruch}`)
|
|
return
|
|
}
|
|
if (daneben.length === 0) {
|
|
note('ok', `${bewegt} verschoben.`)
|
|
} else {
|
|
// Was NICHT ging, wird benannt -- ein "verschoben" ueber einer
|
|
// halben Bewegung waere die Sorte Rueckmeldung, die hier nicht
|
|
// vorkommt.
|
|
note(
|
|
'hinweis',
|
|
`${bewegt} verschoben, ${daneben.length} nicht: ${daneben.slice(0, 3).join('; ')}` +
|
|
(daneben.length > 3 ? ` … und ${daneben.length - 3} weitere` : ''),
|
|
)
|
|
}
|
|
onFertig()
|
|
}
|
|
|
|
async function ordnerAnlegen() {
|
|
const pfad = neuerOrdner.trim()
|
|
if (!pfad) return
|
|
const r = await run(() => api.ordnerAnlegen(pfad), (x) => `Ordner ${x.pfad} angelegt.`)
|
|
if (r) {
|
|
setZiel(r.pfad)
|
|
setNeuerOrdner('')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
|
onClick={onAbbruch}
|
|
>
|
|
<div
|
|
className="w-full max-w-lg rounded-lg border border-rand bg-flaeche shadow-xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="flex items-center gap-2 border-b border-rand px-4 py-3 text-sm font-semibold">
|
|
<FolderInput size={16} className="text-akzent" />
|
|
{ids.length === 1 ? 'Datei verschieben' : `${ids.length} Dateien verschieben`}
|
|
</h3>
|
|
|
|
<div className="flex flex-col gap-3 px-4 py-4">
|
|
<p className="flex items-start gap-2 rounded-md border border-warn/40 bg-warn/10 px-3 py-2 text-sm">
|
|
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-warn" />
|
|
<span className="text-schrift">
|
|
Der Pfad <strong>ist</strong> die öffentliche Adresse. Nach dem
|
|
Verschieben führt die alte URL ins Leere — überall, wo sie steht.
|
|
</span>
|
|
</p>
|
|
|
|
<label className="block">
|
|
<span className="mb-1 block text-xs font-medium text-schrift-leise">
|
|
Zielordner
|
|
</span>
|
|
<select
|
|
value={ziel}
|
|
onChange={(e) => setZiel(e.target.value)}
|
|
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5 text-sm
|
|
text-schrift focus:border-akzent focus:outline-none"
|
|
>
|
|
<option value="">(Wurzel)</option>
|
|
{ordner?.items.map((o) => (
|
|
<option key={o.folder} value={o.folder}>
|
|
{o.folder}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<div className="flex items-end gap-2">
|
|
<Feld
|
|
label="oder neuen Ordner anlegen"
|
|
value={neuerOrdner}
|
|
placeholder="z. B. archiv/2026"
|
|
className="flex-1"
|
|
onChange={(e) => setNeuerOrdner(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
void ordnerAnlegen()
|
|
}
|
|
}}
|
|
/>
|
|
<Knopf
|
|
disabled={!neuerOrdner.trim()}
|
|
onClick={() => void ordnerAnlegen()}
|
|
kind={<Plus size={15} />}
|
|
/>
|
|
</div>
|
|
|
|
{/* Zeigen, WAS daraus wird — nicht nur, wohin es geht. */}
|
|
{beispiel && (
|
|
<div className="rounded border border-rand bg-grund p-2 font-mono text-xs">
|
|
<div className="text-schrift-leise line-through">{beispiel.path}</div>
|
|
<div className="text-gut">{neuerPfad}</div>
|
|
{ids.length > 1 && (
|
|
<div className="mt-1 text-schrift-leise">
|
|
… und {ids.length - 1} weitere
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 border-t border-rand px-4 py-3">
|
|
<Knopf onClick={onAbbruch} kind="Abbrechen" />
|
|
<Knopf
|
|
art="haupt"
|
|
wartet={laeuft}
|
|
onClick={() => void verschieben()}
|
|
kind={
|
|
// Bei tausenden Dateien dauert es; Stillstand ist
|
|
// sonst von Haengen nicht zu unterscheiden.
|
|
laeuft && fortschritt > 0
|
|
? `${fortschritt} von ${ids.length} …`
|
|
: 'Verschieben'
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|