Files
d4rk_media/server/ui/src/components/Ordner.tsx
T
D4rkst3randClaude Opus 5 c9e5bb3c31 Verschieben hatte denselben Fehler -- und Ordner entfernen gab es nur im Dienst
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>
2026-08-26 13:44:38 +02:00

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>
)
}