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>
245 lines
10 KiB
TypeScript
245 lines
10 KiB
TypeScript
import { useState } from 'react'
|
|
import { Check, Folder, FolderOpen, FolderPlus, Layers, PenLine, Trash2, X } from 'lucide-react'
|
|
import { api, type Folders } from '../api'
|
|
import { bytes } from '../format'
|
|
import { run } from '../store'
|
|
|
|
/** Die Ordnerübersicht neben der Galerie.
|
|
*
|
|
* Kein aufklappbarer Baum, sondern eine flache Liste mit Einrückung. Bei
|
|
* einer Handvoll Ordner ist ein Aufklappmechanismus mehr Bedienung als
|
|
* Nutzen — und was man nicht aufklappen muss, kann man auch nicht zugeklappt
|
|
* übersehen.
|
|
*
|
|
* HIER STEHT AUCH "NEUER ORDNER", und das ist eine Korrektur: anlegen ging
|
|
* bisher NUR im Verschieben-Dialog. Wer einen leeren Ordner wollte, musste
|
|
* also erst Dateien auswählen, „Verschieben" drücken und dort das Feld
|
|
* finden — eine Funktion, die man nur über eine andere Funktion erreicht, ist
|
|
* für den, der sie sucht, nicht vorhanden. */
|
|
export function Ordner({
|
|
daten,
|
|
gewaehlt,
|
|
onWahl,
|
|
onAngelegt,
|
|
onUmbenennen,
|
|
onEntfernt,
|
|
}: {
|
|
daten: Folders | null
|
|
gewaehlt: string
|
|
onWahl: (ordner: string) => void
|
|
/** Damit die Liste den neuen Ordner auch zeigt. */
|
|
onAngelegt: () => void
|
|
onUmbenennen: (pfad: string) => void
|
|
/** Nach dem Entfernen muss die Liste neu geladen werden -- sonst steht
|
|
* dort ein Ordner, den es nicht mehr gibt. */
|
|
onEntfernt: () => void
|
|
}) {
|
|
const [neu, setNeu] = useState<string | null>(null)
|
|
const [legtAn, setLegtAn] = useState(false)
|
|
|
|
if (!daten) return null
|
|
|
|
async function entfernen(pfad: string) {
|
|
// Nachfragen, und dabei sagen, was NICHT passiert: der Ordner geht,
|
|
// die Dateien darin gehen nicht -- der Dienst laesst ihn ohnehin nur
|
|
// leer entfernen.
|
|
if (!confirm(`Ordner "${pfad}" entfernen?
|
|
|
|
Er muss leer sein; Dateien darin werden nicht angetastet.`)) {
|
|
return
|
|
}
|
|
const r = await run(() => api.ordnerEntfernen(pfad), `Ordner ${pfad} entfernt.`)
|
|
if (r) onEntfernt()
|
|
}
|
|
|
|
async function anlegen() {
|
|
const name = (neu ?? '').trim()
|
|
if (!name) return
|
|
setLegtAn(true)
|
|
// Relativ zum GEWÄHLTEN Ordner. Wer in "items" steht und "waffen"
|
|
// tippt, meint items/waffen -- alles andere wäre eine Überraschung.
|
|
const pfad = gewaehlt ? `${gewaehlt}/${name}` : name
|
|
const r = await run(() => api.ordnerAnlegen(pfad), (x) => `Ordner ${x.pfad} angelegt.`)
|
|
setLegtAn(false)
|
|
if (r) {
|
|
setNeu(null)
|
|
onAngelegt()
|
|
onWahl(r.pfad)
|
|
}
|
|
}
|
|
|
|
const zeile = (
|
|
aktiv: boolean,
|
|
kind: React.ReactNode,
|
|
name: string,
|
|
anzahl: number,
|
|
groesse: number,
|
|
tiefe = 0,
|
|
onClick?: () => void,
|
|
pfad?: string,
|
|
) => (
|
|
<div
|
|
key={name + tiefe}
|
|
className={`group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-sm
|
|
transition-colors ${
|
|
aktiv
|
|
? 'bg-flaeche-hoch text-schrift'
|
|
: 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift'
|
|
}`}
|
|
style={{ paddingLeft: `${0.5 + tiefe * 0.85}rem` }}
|
|
>
|
|
<button onClick={onClick} className="flex min-w-0 flex-1 items-center gap-2 text-left">
|
|
<span className={aktiv ? 'text-akzent' : ''}>{kind}</span>
|
|
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={name}>
|
|
{name}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Erst beim Darueberfahren. Eine Zeile mit drei Zahlen und einem
|
|
dauerhaft sichtbaren Knopf waere Laerm — und bei einem Dutzend
|
|
Ordnern zwoelfmal derselbe. */}
|
|
{pfad !== undefined && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onUmbenennen(pfad)
|
|
}}
|
|
className="shrink-0 text-schrift-leise opacity-0 transition-opacity
|
|
group-hover:opacity-100 hover:text-schrift focus-visible:opacity-100"
|
|
aria-label={`${pfad} umbenennen`}
|
|
title="Umbenennen"
|
|
>
|
|
<PenLine size={12} />
|
|
</button>
|
|
)}
|
|
|
|
{/* ORDNER ENTFERNEN. Den Weg gab es im Dienst und in `api.ts`
|
|
laengst -- es fehlte allein der Knopf, und damit war die
|
|
Funktion fuer den, der sie sucht, nicht vorhanden. Dieselbe
|
|
Lehre wie beim "Neuer Ordner" ein paar Zeilen weiter oben.
|
|
|
|
DIE PRUEFUNG BLEIBT BEIM DIENST: er lehnt einen Ordner mit
|
|
Inhalt ab und sagt, wieviele Dateien darin liegen. Diese Regel
|
|
hier zu wiederholen hiesse, sie zweimal zu pflegen -- und die
|
|
zweite Fassung ist irgendwann die falsche. */}
|
|
{pfad !== undefined && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
void entfernen(pfad)
|
|
}}
|
|
className="shrink-0 text-schrift-leise opacity-0 transition-opacity
|
|
group-hover:opacity-100 hover:text-schlecht focus-visible:opacity-100"
|
|
aria-label={`${pfad} entfernen`}
|
|
title="Ordner entfernen (muss leer sein)"
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
)}
|
|
|
|
<span className="shrink-0 text-[11px] tabular-nums text-schrift-leise">
|
|
{anzahl}
|
|
</span>
|
|
<span className="w-14 shrink-0 text-right text-[11px] tabular-nums text-schrift-leise">
|
|
{bytes(groesse)}
|
|
</span>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<aside className="w-full shrink-0 self-start rounded-lg border border-rand bg-flaeche p-2 lg:w-60">
|
|
<h2 className="px-2 py-1 text-xs font-semibold text-schrift-leise">Ordner</h2>
|
|
|
|
{zeile(
|
|
gewaehlt === '',
|
|
<Layers size={14} />,
|
|
'Alle',
|
|
daten.gesamt.files,
|
|
daten.gesamt.bytes,
|
|
0,
|
|
() => onWahl(''),
|
|
)}
|
|
|
|
{daten.items.map((o) =>
|
|
zeile(
|
|
gewaehlt === o.folder,
|
|
gewaehlt === o.folder ? <FolderOpen size={14} /> : <Folder size={14} />,
|
|
// Nur das letzte Stück anzeigen — der Weg dorthin steht
|
|
// schon in der Einrückung. Der volle Pfad hängt im title.
|
|
o.folder.split('/').pop() ?? o.folder,
|
|
o.files,
|
|
o.bytes,
|
|
o.tiefe,
|
|
() => onWahl(o.folder),
|
|
o.folder,
|
|
),
|
|
)}
|
|
|
|
{daten.wurzel.files > 0 && (
|
|
<p className="mt-2 px-2 text-[11px] text-schrift-leise">
|
|
{daten.wurzel.files} Datei(en) liegen ohne Ordner direkt an der Wurzel — zu
|
|
sehen unter „Alle".
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-2 border-t border-rand pt-2">
|
|
{neu === null ? (
|
|
<button
|
|
onClick={() => setNeu('')}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
|
|
text-xs text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift"
|
|
>
|
|
<FolderPlus size={14} />
|
|
Neuer Ordner
|
|
</button>
|
|
) : (
|
|
<div className="flex flex-col gap-1.5 px-1">
|
|
{/* WO er entsteht, steht dabei. Ohne diese Zeile tippt
|
|
jemand "waffen", landet in "items/waffen" und sucht
|
|
danach an der Wurzel. */}
|
|
<p className="px-1 text-[11px] text-schrift-leise">
|
|
in{' '}
|
|
<code className="font-mono">{gewaehlt || '(Wurzel)'}</code>
|
|
</p>
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
autoFocus
|
|
value={neu}
|
|
onChange={(e) => setNeu(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') void anlegen()
|
|
else if (e.key === 'Escape') setNeu(null)
|
|
}}
|
|
placeholder="name"
|
|
className="min-w-0 flex-1 rounded border border-rand bg-grund px-2 py-1
|
|
font-mono text-xs text-schrift outline-none
|
|
focus:border-akzent"
|
|
/>
|
|
<button
|
|
onClick={() => void anlegen()}
|
|
disabled={legtAn || !neu.trim()}
|
|
className="rounded border border-rand p-1 text-schrift-leise
|
|
hover:bg-flaeche-hoch hover:text-gut disabled:opacity-40"
|
|
aria-label="Anlegen"
|
|
>
|
|
<Check size={13} />
|
|
</button>
|
|
<button
|
|
onClick={() => setNeu(null)}
|
|
className="rounded border border-rand p-1 text-schrift-leise
|
|
hover:bg-flaeche-hoch hover:text-schrift"
|
|
aria-label="Abbrechen"
|
|
>
|
|
<X size={13} />
|
|
</button>
|
|
</div>
|
|
<p className="px-1 text-[11px] text-schrift-leise">
|
|
Buchstabe oder Ziffer zuerst, danach auch Punkt, Strich, Unterstrich.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|