Compare commits
6
Commits
0f0097ba3c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06285a3a53 | ||
|
|
c9e5bb3c31 | ||
|
|
8a17b8b974 | ||
|
|
a99e792194 | ||
|
|
a7e9332bad | ||
|
|
a373b962d0 |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user