Compare commits

...
6 Commits
Author SHA1 Message Date
D4rkst3randClaude Opus 5 06285a3a53 Ein Token darf jetzt mehrere Ordner -- statt einem oder allen
Das Fotostudio schreibt nach vehicles/ UND items/, der Token war aber auf
"vehicles" gefesselt. Damit blieb nur die Wahl zwischen "einer davon" und
"darf ueberall": das eine lehnt die Haelfte der Uploads mit 403 ab, das andere
erlaubt auch sicherungen/.

Gemessen und dabei aufgefallen: config.upload.lua setzt kein clothingPath,
also gilt die Vorgabe items/{model}.png -- und die durfte dieser Token nicht.
Kleidungs-Uploads aus dem Studio sind also stillschweigend an 403 gescheitert.

Die Spalte bleibt ein Textfeld, mehrere Werte stehen durch Komma darin. Damit
gilt JEDER bestehende Token unveraendert weiter: ein Wert ohne Komma ist eine
Liste mit einem Eintrag. Kein Migrationsschritt, kein Sonderfall.

Geprueft mit acht Faellen, darunter zwei, die scheitern MUESSEN: items/ unter
der Fessel "vehicles" (403) und "vehiclesXYZ/a.png" unter "vehicles" -- das
angehaengte / war schon vorher da und bleibt, sonst wuerde ein Praefix seinen
eigenen Namensvetter durchlassen.

Die 403-Meldung nennt jetzt alle erlaubten Ordner. Vorher haette sie bei
mehreren Fesseln "vehicles,items/" geschrieben -- einen Ordner, den es nicht
gibt. Wer abgewiesen wird, soll lesen koennen, wohin er darf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:57:17 +02:00
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
D4rkst3randClaude Opus 5 8a17b8b974 Loeschen loeschte nur die sichtbare Seite
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>
2026-08-23 11:55:26 +02:00
D4rkst3r a99e792194 gitignore: die Arbeitsliste 2026-08-22 23:48:50 +02:00
D4rkst3randClaude Opus 5 a7e9332bad Die Arbeitsliste gehoert nicht ins Repo
fehlende-item-bilder.txt war eine Momentaufnahme fuer den Betreiber und ist
seit der Umbenennung ohnehin ueberholt. Die Datei bleibt lokal liegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:48:50 +02:00
D4rkst3randClaude Opus 5 a373b962d0 Beim Hochladen bleibt die Schreibweise -- sie war eine stille Verstuemmelung
Der Betreiber setzte in FiveM `inventory:imagepath` auf das Portal, und die
meisten Item-Icons blieben leer. Gemessen:

    WEAPON_COMBATPISTOL.png  -> 404
    weapon_combatpistol.png  -> 200

ox_inventory fragt Item-Bilder unter dem EXAKTEN Item-Namen ab; seine Waffen
heissen WEAPON_*. Der Dienst laeuft auf Linux, dort ist die Schreibweise ein
Unterschied. Zur Gegenprobe die mitgelieferten Bilder von ox_inventory selbst:
113 von 198 sind grossgeschrieben.

Die Ursache lag hier: `zaehmen()` machte beim Hochladen per Fenster jeden
Namen klein -- "vorhersagbar". Fuer die ZEICHEN war das richtig (Leerzeichen,
Klammern, Umlaute haben in einer Adresse nichts verloren), fuer die
SCHREIBWEISE war es nie noetig: checkPath erlaubt [A-Za-z0-9] seit jeher, und
in einer URL ist ein Grossbuchstabe kein Problem.

Also bleibt die Schreibweise jetzt stehen. Umlaute werden weiter ersetzt, nun
in beiden Formen -- sonst wuerde aus "Gruen.png" ein "gr-n.png", dieselbe
stille Verstuemmelung eine Ebene tiefer. Die ENDUNG bleibt klein: daran haengt
nur der Dateityp, und ".PNG" waere eine Adresse, die niemand so tippt.

Dazu 109 vorhandene Bilder umbenannt (ueber moveFile, damit Vorschau und WebP
mitwandern; jede Umbenennung steht im Ereignisprotokoll). Vorher: 0 Kollisionen
im Trockenlauf. Nachher gemessen: WEAPON_COMBATPISTOL 200, und 156 von 226
Items haben ein exakt passendes Bild statt 47.

EIGENER FEHLER, DER DAS VERZOEGERT HAT: mein erster Abgleich verglich beide
Seiten kleingeschrieben und hat damit genau den Unterschied wegnormalisiert,
auf den es ankam -- ich meldete 56 leere Felder statt 174. Eine Pruefung, die
das Falsche misst, ist schlimmer als keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:48:36 +02:00
10 changed files with 317 additions and 72 deletions
+1
View File
@@ -13,3 +13,4 @@ server/web/
# Die Ablage des Dienstes: Bilder und SQLite. Gehoert ins Volume, nie ins Repo. # Die Ablage des Dienstes: Bilder und SQLite. Gehoert ins Volume, nie ins Repo.
data/ data/
.waechter-basis.json .waechter-basis.json
fehlende-item-bilder.txt
+37 -3
View File
@@ -147,10 +147,44 @@ export function tokenFromHeader(header: string | undefined): Token | null {
} }
/** Darf dieser Token auf diesen Pfad schreiben? */ /** Darf dieser Token auf diesen Pfad schreiben? */
/**
* Die Fesseln eines Tokens als Liste.
*
* MEHRERE STATT EINER, seit dem 23.08.2026. Das Fotostudio schreibt nach
* `vehicles/` UND `items/`, und mit einer einzigen Fessel blieb nur die Wahl
* zwischen "einer davon" und "darf ueberall". Beides war falsch: das eine
* lehnt die Haelfte der Uploads mit 403 ab, das andere erlaubt auch
* `sicherungen/`.
*
* Die Spalte bleibt, was sie war -- ein Textfeld. Mehrere Werte stehen durch
* Komma getrennt darin. Damit gilt jeder BESTEHENDE Token unveraendert
* weiter: ein Wert ohne Komma ist eine Liste mit einem Eintrag.
*/
export function tokenPrefixes(token: Pick<Token, 'prefix'>): string[] {
return (token.prefix ?? '')
.split(',')
.map((p) => p.trim().replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
}
/** Die Fesseln als Satzstueck fuer eine Fehlermeldung.
*
* Aus der rohen Spalte wuerde sonst `"vehicles,items/"` -- ein Ordner, den es
* nicht gibt. Wer eine 403 bekommt, soll lesen koennen, wohin er DARF. */
export function fesselText(token: Pick<Token, 'prefix'>): string {
const f = tokenPrefixes(token)
if (f.length === 0) return 'ueberall'
if (f.length === 1) return `"${f[0]}/"`
return f.map((p) => `"${p}/"`).join(' oder ')
}
export function tokenAllows(token: Token, path: string): boolean { export function tokenAllows(token: Token, path: string): boolean {
if (!token.prefix) return true const fesseln = tokenPrefixes(token)
const prefix = token.prefix.replace(/\/+$/, '') + '/' // Keine Fessel heisst ueberall -- so war es, so bleibt es.
return path.startsWith(prefix) if (fesseln.length === 0) return true
// Das `/` gehoert dazu: ohne es wuerde "vehicles" auch "vehiclesXYZ/a.png"
// durchlassen. Stand schon vorher so da und bleibt.
return fesseln.some((p) => path.startsWith(p + '/'))
} }
// -------------------------------------------------------------------- Start // -------------------------------------------------------------------- Start
+24 -2
View File
@@ -2913,6 +2913,28 @@ dashRoutes.get('/tokens', (c) => {
}) })
}) })
/** Aus der Eingabe eine geprueste Fesselliste machen.
*
* Mehrere Ordner werden durch Komma getrennt. JEDER wird einzeln nach den
* Pfadregeln geprueft -- eine Liste, bei der ein Eintrag durchrutscht, ist
* keine Pruefung. Doppelte fallen weg, damit die Meldung spaeter nicht
* denselben Ordner zweimal nennt.
*
* Leer bleibt leer, und das heisst weiterhin "darf ueberall".
*/
function fesselnPruefen(roh: string): string {
const teile = roh
.split(',')
.map((t) => t.trim().replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
const raus: string[] = []
for (const t of teile) {
const sauber = checkPath(t)
if (!raus.includes(sauber)) raus.push(sauber)
}
return raus.join(',')
}
dashRoutes.post('/tokens', async (c) => { dashRoutes.post('/tokens', async (c) => {
const body = await jsonBody<{ const body = await jsonBody<{
name?: string name?: string
@@ -2996,7 +3018,7 @@ dashRoutes.post('/tokens', async (c) => {
let cleanPrefix = '' let cleanPrefix = ''
if (prefix?.trim()) { if (prefix?.trim()) {
try { try {
cleanPrefix = checkPath(prefix.trim().replace(/\/+$/, '')) cleanPrefix = fesselnPruefen(prefix)
} catch (err) { } catch (err) {
if (err instanceof PathError) return c.json({ error: err.message }, 400) if (err instanceof PathError) return c.json({ error: err.message }, 400)
throw err throw err
@@ -3082,7 +3104,7 @@ dashRoutes.patch('/tokens/:id', async (c) => {
const roh = body.prefix.trim().replace(/^\/+|\/+$/g, '') const roh = body.prefix.trim().replace(/^\/+|\/+$/g, '')
if (roh) { if (roh) {
try { try {
feld.prefix = checkPath(roh) feld.prefix = fesselnPruefen(roh)
} catch (err) { } catch (err) {
if (err instanceof PathError) return c.json({ error: `Praefix: ${err.message}` }, 400) if (err instanceof PathError) return c.json({ error: `Praefix: ${err.message}` }, 400)
throw err throw err
+2 -2
View File
@@ -30,7 +30,7 @@ import type { Context, Next } from 'hono'
import { config } from '../config.js' import { config } from '../config.js'
import { meldeUpload } from '../meldung.js' import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js' import { db, logEvent, now, type Media, type Token } from '../db.js'
import { tokenAllows, tokenFromHeader } from '../auth.js' import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js'
import { kontingentUeberschritten } from './upload.js' import { kontingentUeberschritten } from './upload.js'
import { import {
checkPath, checkPath,
@@ -252,7 +252,7 @@ async function ablegen(
antwort: c.json( antwort: c.json(
{ {
status: 'error', status: 'error',
message: `dieser Token darf nur unter "${token.prefix}/" schreiben`, message: `dieser Token darf nur unter ${fesselText(token)} schreiben`,
}, },
403, 403,
), ),
+2 -2
View File
@@ -24,7 +24,7 @@ import type { Context, Next } from 'hono'
import { config } from '../config.js' import { config } from '../config.js'
import { meldeUpload } from '../meldung.js' import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js' import { db, logEvent, now, type Media, type Token } from '../db.js'
import { tokenAllows, tokenFromHeader } from '../auth.js' import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js'
import { import {
PathError, PathError,
checkPath, checkPath,
@@ -236,7 +236,7 @@ uploadRoutes.post('/upload', async (c) => {
if (!tokenAllows(token, path)) { if (!tokenAllows(token, path)) {
return c.json( return c.json(
{ error: `dieser Token darf nur unter "${token.prefix}/" schreiben` }, { error: `dieser Token darf nur unter ${fesselText(token)} schreiben` },
403, 403,
) )
} }
+31 -5
View File
@@ -135,21 +135,47 @@ export function safeFilename(name: string, eindeutig?: string): string {
const roh = punkt > 0 ? name.slice(0, punkt) : name const roh = punkt > 0 ? name.slice(0, punkt) : name
const endung = punkt > 0 ? name.slice(punkt + 1) : '' const endung = punkt > 0 ? name.slice(punkt + 1) : ''
// DIE SCHREIBWEISE BLEIBT, seit dem 22.08.2026.
//
// Hier stand `.toLowerCase()` mit der Begruendung "vorhersagbar". Das war
// fuer die Zeichen richtig und fuer die GROSSSCHREIBUNG falsch, und es hat
// etwas kaputtgemacht, das niemand mit dem Hochladen in Verbindung brachte:
//
// ox_inventory fragt Item-Bilder unter dem EXAKTEN Item-Namen ab. Seine
// Waffen heissen WEAPON_COMBATPISTOL. Der Ordner lag auf einem
// Linux-Wirt, dort ist Gross- und Kleinschreibung ein Unterschied --
// also 404, also leeres Feld im Inventar. Gemessen am 22.08.2026:
// 109 von 226 Items betroffen, und ox_inventory liefert seine eigenen
// Bilder genau deshalb GROSS aus (113 von 198).
//
// Der Grund fuer das Zaehmen bleibt richtig -- Leerzeichen, Klammern und
// Umlaute haben in einer Adresse nichts verloren. Nur die Schreibweise
// gehoerte nie dazu: `checkPath` erlaubt `[A-Za-z0-9]` seit jeher, und in
// einer URL ist ein Grossbuchstabe kein Problem.
//
// Umlaute werden weiterhin ersetzt, jetzt in beiden Schreibweisen -- sonst
// wuerde aus "Gruen.png" ein "gr-n.png", und das waere dieselbe stille
// Verstuemmelung eine Ebene tiefer.
const zaehmen = (s: string) => const zaehmen = (s: string) =>
s s
.toLowerCase()
// Umlaute NICHT einfach wegwerfen: "grün" wuerde sonst zu "grn".
.replace(/ä/g, 'ae') .replace(/ä/g, 'ae')
.replace(/Ä/g, 'Ae')
.replace(/ö/g, 'oe') .replace(/ö/g, 'oe')
.replace(/Ö/g, 'Oe')
.replace(/ü/g, 'ue') .replace(/ü/g, 'ue')
.replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss') .replace(/ß/g, 'ss')
.replace(/[^a-z0-9._-]+/g, '-') .replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/-{2,}/g, '-') .replace(/-{2,}/g, '-')
.replace(/^[^a-z0-9]+/, '') .replace(/^[^A-Za-z0-9]+/, '')
.replace(/[-.]+$/, '') .replace(/[-.]+$/, '')
const stamm = zaehmen(roh) const stamm = zaehmen(roh)
const ende = zaehmen(endung) // DIE ENDUNG BLEIBT KLEIN. Am Stamm haengt die Bedeutung -- an der Endung
// haengt nur der Dateityp, und die schreibt alle Welt klein. Wer
// "Bild.PNG" hochlaedt, will kein zweites Format, sondern ein PNG; ein
// ".PNG" waere eine Adresse, die niemand so tippt.
const ende = zaehmen(endung).toLowerCase()
// Bleibt nichts uebrig — etwa bei einem rein kyrillischen Namen —, ist das // Bleibt nichts uebrig — etwa bei einem rein kyrillischen Namen —, ist das
// kein Fehlerfall fuer den Aufrufer: er bekommt einen erzeugten Namen und // kein Fehlerfall fuer den Aufrufer: er bekommt einen erzeugten Namen und
+42 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Check, Folder, FolderOpen, FolderPlus, Layers, PenLine, X } from 'lucide-react' import { Check, Folder, FolderOpen, FolderPlus, Layers, PenLine, Trash2, X } from 'lucide-react'
import { api, type Folders } from '../api' import { api, type Folders } from '../api'
import { bytes } from '../format' import { bytes } from '../format'
import { run } from '../store' import { run } from '../store'
@@ -22,6 +22,7 @@ export function Ordner({
onWahl, onWahl,
onAngelegt, onAngelegt,
onUmbenennen, onUmbenennen,
onEntfernt,
}: { }: {
daten: Folders | null daten: Folders | null
gewaehlt: string gewaehlt: string
@@ -29,12 +30,28 @@ export function Ordner({
/** Damit die Liste den neuen Ordner auch zeigt. */ /** Damit die Liste den neuen Ordner auch zeigt. */
onAngelegt: () => void onAngelegt: () => void
onUmbenennen: (pfad: string) => 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 [neu, setNeu] = useState<string | null>(null)
const [legtAn, setLegtAn] = useState(false) const [legtAn, setLegtAn] = useState(false)
if (!daten) return null 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() { async function anlegen() {
const name = (neu ?? '').trim() const name = (neu ?? '').trim()
if (!name) return if (!name) return
@@ -96,6 +113,30 @@ export function Ordner({
</button> </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"> <span className="shrink-0 text-[11px] tabular-nums text-schrift-leise">
{anzahl} {anzahl}
</span> </span>
+61 -21
View File
@@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { AlertTriangle, FolderInput, Plus } from 'lucide-react' import { AlertTriangle, FolderInput, Plus } from 'lucide-react'
import { api, type Folders, type MediaItem } from '../api' import { api, type Folders, type MediaItem } from '../api'
import { run } from '../store' import { run, useStore } from '../store'
import { Feld, Knopf } from './Bausteine' import { Feld, Knopf } from './Bausteine'
/** Dateien in einen anderen Ordner legen. /** Dateien in einen anderen Ordner legen.
@@ -12,12 +12,17 @@ import { Feld, Knopf } from './Bausteine'
* Zwischenspeicher. Das ist kein Fehler, sondern der Preis vorhersagbarer * Zwischenspeicher. Das ist kein Fehler, sondern der Preis vorhersagbarer
* Adressen; nur muss man es vorher wissen und nicht hinterher suchen. */ * Adressen; nur muss man es vorher wissen und nicht hinterher suchen. */
export function Verschieben({ export function Verschieben({
dateien, ids,
namen,
ordner, ordner,
onFertig, onFertig,
onAbbruch, onAbbruch,
}: { }: {
dateien: MediaItem[] /** 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 ordner: Folders | null
onFertig: () => void onFertig: () => void
onAbbruch: () => void onAbbruch: () => void
@@ -25,27 +30,56 @@ export function Verschieben({
const [ziel, setZiel] = useState('') const [ziel, setZiel] = useState('')
const [neuerOrdner, setNeuerOrdner] = useState('') const [neuerOrdner, setNeuerOrdner] = useState('')
const [laeuft, setLaeuft] = useState(false) const [laeuft, setLaeuft] = useState(false)
const [fortschritt, setFortschritt] = useState(0)
const beispiel = dateien[0] const beispiel = namen[0]
const name = beispiel?.path.split('/').pop() ?? '' const name = beispiel?.path.split('/').pop() ?? ''
const neuerPfad = ziel ? `${ziel}/${name}` : name const neuerPfad = ziel ? `${ziel}/${name}` : name
async function verschieben() { async function verschieben() {
setLaeuft(true) setLaeuft(true)
const r = await run( const { note } = useStore.getState()
() => api.verschieben(dateien.map((d) => d.id), ziel),
(x) => // IN HAEPPCHEN, wie beim Loeschen: der Dienst verschiebt jede Datei
x.fehlgeschlagen === 0 // einzeln, und tausende in einer Anfrage laufen in die Zeitgrenze des
? `${x.verschoben} verschoben.` // Proxys. 200 je Runde, und der Knopf sagt, wie weit es ist.
: `${x.verschoben} verschoben, ${x.fehlgeschlagen} nicht: ` + const PORTION = 200
x.ergebnisse let bewegt = 0
.filter((e) => !e.ok) let daneben: string[] = []
.slice(0, 3) let abbruch: string | null = null
.map((e) => `${e.path} (${e.error})`)
.join('; '), 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) setLaeuft(false)
if (r) onFertig() 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() { async function ordnerAnlegen() {
@@ -69,7 +103,7 @@ export function Verschieben({
> >
<h3 className="flex items-center gap-2 border-b border-rand px-4 py-3 text-sm font-semibold"> <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" /> <FolderInput size={16} className="text-akzent" />
{dateien.length === 1 ? 'Datei verschieben' : `${dateien.length} Dateien verschieben`} {ids.length === 1 ? 'Datei verschieben' : `${ids.length} Dateien verschieben`}
</h3> </h3>
<div className="flex flex-col gap-3 px-4 py-4"> <div className="flex flex-col gap-3 px-4 py-4">
@@ -126,9 +160,9 @@ export function Verschieben({
<div className="rounded border border-rand bg-grund p-2 font-mono text-xs"> <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-schrift-leise line-through">{beispiel.path}</div>
<div className="text-gut">{neuerPfad}</div> <div className="text-gut">{neuerPfad}</div>
{dateien.length > 1 && ( {ids.length > 1 && (
<div className="mt-1 text-schrift-leise"> <div className="mt-1 text-schrift-leise">
und {dateien.length - 1} weitere und {ids.length - 1} weitere
</div> </div>
)} )}
</div> </div>
@@ -141,7 +175,13 @@ export function Verschieben({
art="haupt" art="haupt"
wartet={laeuft} wartet={laeuft}
onClick={() => void verschieben()} onClick={() => void verschieben()}
kind="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> </div>
+107 -31
View File
@@ -60,12 +60,24 @@ export function Galerie() {
const [daten, setDaten] = useState<MediaPage | null>(null) const [daten, setDaten] = useState<MediaPage | null>(null)
const [laedt, setLaedt] = useState(true) const [laedt, setLaedt] = useState(true)
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set()) const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
const [loeschen, setLoeschen] = useState<MediaItem[] | null>(null) /** 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) const [loescht, setLoescht] = useState(false)
// Welche Datei gerade gross zu sehen ist -- als ID und nicht als Objekt: // 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. // nach einem Neuladen der Liste zeigt sie sonst auf einen alten Datensatz.
const [ansehen, setAnsehen] = useState<number | null>(null) const [ansehen, setAnsehen] = useState<number | null>(null)
const [verschieben, setVerschieben] = useState<MediaItem[] | null>(null) /** Dieselbe Trennung wie beim Loeschen -- und aus demselben Grund: hier
* stand `items.filter(...)`, also die offene Seite, und von 3565
* gewaehlten Bildern wanderten 60. */
const [verschieben, setVerschieben] = useState<{ ids: number[]; namen: MediaItem[] } | null>(null)
const [umbenennen, setUmbenennen] = useState<MediaItem | null>(null) const [umbenennen, setUmbenennen] = useState<MediaItem | null>(null)
const [ordnerUmbenennen, setOrdnerUmbenennen] = useState<string | null>(null) const [ordnerUmbenennen, setOrdnerUmbenennen] = useState<string | null>(null)
const { kopiert, kopieren } = useKopieren() const { kopiert, kopieren } = useKopieren()
@@ -197,23 +209,53 @@ export function Galerie() {
async function loeschenAusfuehren() { async function loeschenAusfuehren() {
if (!loeschen) return if (!loeschen) return
setLoescht(true) setLoescht(true)
setLoeschFortschritt(0)
const ergebnis = // IN HAEPPCHEN, nicht in einem Zug.
loeschen.length === 1 //
? await run( // Der Dienst verschiebt jede Datei einzeln in den Papierkorb. Bei 3565
() => api.deleteMedia(loeschen[0]!.id), // Bildern ist das eine Anfrage, die Minuten laeuft -- und die faellt
(r) => `${r.deleted} gelöscht.`, // beim Proxy in die Zeitgrenze, lange bevor sie fertig ist. Dann waere
) // die Haelfte geloescht und die Oberflaeche meldete einen Fehler.
: await run( //
() => api.deleteMediaBulk(loeschen.map((i) => i.id)), // 200 je Runde: gross genug, dass es nicht an der Anzahl der Anfragen
(r) => // haengt, klein genug fuer eine Antwort in Sekunden. Und nach jeder
r.count === loeschen.length // Runde steht im Knopf, wie weit es ist -- bei etwas, das eine Minute
? `${r.count} Dateien gelöscht.` // dauert, ist Stillstand nicht von Haengen zu unterscheiden.
: // Der Dienst überspringt, was er nicht findet. const { note } = useStore.getState()
// Diese Lücke zu verschweigen wäre genau die const PORTION = 200
// Sorte Rückmeldung, die hier nicht vorkommt. const alle = loeschen.ids
`${r.count} von ${loeschen.length} gelöscht — der Rest war schon weg.`, 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) setLoescht(false)
setLoeschen(null) setLoeschen(null)
@@ -243,6 +285,7 @@ export function Galerie() {
daten={ordnerDaten} daten={ordnerDaten}
gewaehlt={ordner} gewaehlt={ordner}
onAngelegt={() => void ordnerLaden()} onAngelegt={() => void ordnerLaden()}
onEntfernt={() => void ordnerLaden()}
onUmbenennen={setOrdnerUmbenennen} onUmbenennen={setOrdnerUmbenennen}
onWahl={(o) => { onWahl={(o) => {
setOrdner(o) setOrdner(o)
@@ -428,7 +471,12 @@ export function Galerie() {
{gewaehlt.size > 0 && ( {gewaehlt.size > 0 && (
<Knopf <Knopf
onClick={() => setVerschieben(items.filter((i) => gewaehlt.has(i.id)))} onClick={() =>
setVerschieben({
ids: [...gewaehlt],
namen: items.filter((i) => gewaehlt.has(i.id)),
})
}
kind={ kind={
<> <>
<FolderInput size={15} /> <FolderInput size={15} />
@@ -441,11 +489,23 @@ export function Galerie() {
{gewaehlt.size > 0 && ( {gewaehlt.size > 0 && (
<Knopf <Knopf
art="gefahr" art="gefahr"
onClick={() => setLoeschen(items.filter((i) => gewaehlt.has(i.id)))} onClick={() =>
setLoeschen({
// ALLE Gewaehlten, nicht nur die sichtbaren.
ids: [...gewaehlt],
namen: items.filter((i) => gewaehlt.has(i.id)),
})
}
kind={ kind={
<> <>
<Trash2 size={15} /> <Trash2 size={15} />
{gewaehlt.size} löschen {/* 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`}
</> </>
} }
/> />
@@ -611,7 +671,7 @@ export function Galerie() {
<ExternalLink size={11} /> <ExternalLink size={11} />
</a> </a>
<button <button
onClick={() => setLoeschen([item])} onClick={() => setLoeschen({ ids: [item.id], namen: [item] })}
className="rounded border border-rand px-2 py-1 text-schrift-leise className="rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-schlecht/15 hover:text-schlecht" hover:bg-schlecht/15 hover:text-schlecht"
aria-label={`${item.path} löschen`} aria-label={`${item.path} löschen`}
@@ -666,7 +726,7 @@ export function Galerie() {
onSchliessen={() => setAnsehen(null)} onSchliessen={() => setAnsehen(null)}
onLoeschen={() => { onLoeschen={() => {
setAnsehen(null) setAnsehen(null)
setLoeschen([item]) setLoeschen({ ids: [item.id], namen: [item] })
}} }}
onUmbenennen={() => { onUmbenennen={() => {
setAnsehen(null) setAnsehen(null)
@@ -709,7 +769,8 @@ export function Galerie() {
{verschieben && ( {verschieben && (
<Verschieben <Verschieben
dateien={verschieben} ids={verschieben.ids}
namen={verschieben.namen}
ordner={ordnerDaten} ordner={ordnerDaten}
onAbbruch={() => setVerschieben(null)} onAbbruch={() => setVerschieben(null)}
onFertig={() => { onFertig={() => {
@@ -724,22 +785,37 @@ export function Galerie() {
<Nachfrage <Nachfrage
offen={loeschen !== null} offen={loeschen !== null}
titel={loeschen?.length === 1 ? 'Datei löschen?' : `${loeschen?.length} Dateien löschen?`} titel={
bestaetigung="Endgültig löschen" loeschen?.ids.length === 1
? 'Datei löschen?'
: `${loeschen?.ids.length ?? 0} Dateien löschen?`
}
bestaetigung="In den Papierkorb"
wartet={loescht} wartet={loescht}
onNein={() => setLoeschen(null)} onNein={() => setLoeschen(null)}
onJa={() => void loeschenAusfuehren()} onJa={() => void loeschenAusfuehren()}
text={ 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"> <p className="mb-2">
Die Datei wird von der Platte entfernt. Es gibt keine Sicherung, aus Verschiebt in den <b>Papierkorb</b> dort liegt alles noch 30 Tage und
der sie zurückkommt. lässt sich zurückholen. Erst danach ist es endgültig weg.
</p> </p>
<ul className="max-h-40 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-xs"> <ul className="max-h-40 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-xs">
{loeschen?.slice(0, 12).map((i) => <li key={i.id}>{i.path}</li>)} {loeschen?.namen.slice(0, 12).map((i) => <li key={i.id}>{i.path}</li>)}
{loeschen && loeschen.length > 12 && ( {/* 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"> <li className="text-schrift-leise">
und {loeschen.length - 12} weitere und {loeschen.ids.length - loeschen.namen.slice(0, 12).length} weitere
</li> </li>
)} )}
</ul> </ul>
+10 -5
View File
@@ -98,10 +98,10 @@ export function Tokens() {
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
<Feld <Feld
label="Präfix (optional)" label="Ordner (optional)"
value={prefix} value={prefix}
placeholder="vehicles" placeholder="vehicles, items"
hinweis="Der Token darf dann nur darunter schreiben." hinweis="Nur unter diesen Ordnern darf der Token schreiben. Mehrere durch Komma. Leer = überall."
onChange={(e) => setPrefix(e.target.value)} onChange={(e) => setPrefix(e.target.value)}
/> />
</div> </div>
@@ -237,7 +237,7 @@ export function Tokens() {
<thead> <thead>
<tr className="text-left text-xs text-schrift-leise"> <tr className="text-left text-xs text-schrift-leise">
<th className="pb-2 font-medium">Name</th> <th className="pb-2 font-medium">Name</th>
<th className="pb-2 font-medium">Präfix</th> <th className="pb-2 font-medium">Ordner</th>
<th className="pb-2 font-medium">Rechte</th> <th className="pb-2 font-medium">Rechte</th>
<th className="pb-2 font-medium">Grenzen</th> <th className="pb-2 font-medium">Grenzen</th>
{/* Was mit dem Token wirklich abgelegt wurde. {/* Was mit dem Token wirklich abgelegt wurde.
@@ -266,7 +266,12 @@ export function Tokens() {
)} )}
</td> </td>
<td className="py-2 font-mono text-schrift-leise"> <td className="py-2 font-mono text-schrift-leise">
{t.prefix ? `${t.prefix}/` : '— (überall)'} {t.prefix
? t.prefix
.split(',')
.map((p) => `${p.trim()}/`)
.join(' · ')
: '— (überall)'}
</td> </td>
<td className="py-2"> <td className="py-2">
{t.can_delete ? ( {t.can_delete ? (