Compare commits
3
Commits
a99e792194
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06285a3a53 | ||
|
|
c9e5bb3c31 | ||
|
|
8a17b8b974 |
+37
-3
@@ -147,10 +147,44 @@ export function tokenFromHeader(header: string | undefined): Token | null {
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
if (!token.prefix) return true
|
||||
const prefix = token.prefix.replace(/\/+$/, '') + '/'
|
||||
return path.startsWith(prefix)
|
||||
const fesseln = tokenPrefixes(token)
|
||||
// Keine Fessel heisst ueberall -- so war es, so bleibt es.
|
||||
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
|
||||
|
||||
@@ -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) => {
|
||||
const body = await jsonBody<{
|
||||
name?: string
|
||||
@@ -2996,7 +3018,7 @@ dashRoutes.post('/tokens', async (c) => {
|
||||
let cleanPrefix = ''
|
||||
if (prefix?.trim()) {
|
||||
try {
|
||||
cleanPrefix = checkPath(prefix.trim().replace(/\/+$/, ''))
|
||||
cleanPrefix = fesselnPruefen(prefix)
|
||||
} catch (err) {
|
||||
if (err instanceof PathError) return c.json({ error: err.message }, 400)
|
||||
throw err
|
||||
@@ -3082,7 +3104,7 @@ dashRoutes.patch('/tokens/:id', async (c) => {
|
||||
const roh = body.prefix.trim().replace(/^\/+|\/+$/g, '')
|
||||
if (roh) {
|
||||
try {
|
||||
feld.prefix = checkPath(roh)
|
||||
feld.prefix = fesselnPruefen(roh)
|
||||
} catch (err) {
|
||||
if (err instanceof PathError) return c.json({ error: `Praefix: ${err.message}` }, 400)
|
||||
throw err
|
||||
|
||||
@@ -30,7 +30,7 @@ import type { Context, Next } from 'hono'
|
||||
import { config } from '../config.js'
|
||||
import { meldeUpload } from '../meldung.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 {
|
||||
checkPath,
|
||||
@@ -252,7 +252,7 @@ async function ablegen(
|
||||
antwort: c.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: `dieser Token darf nur unter "${token.prefix}/" schreiben`,
|
||||
message: `dieser Token darf nur unter ${fesselText(token)} schreiben`,
|
||||
},
|
||||
403,
|
||||
),
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { Context, Next } from 'hono'
|
||||
import { config } from '../config.js'
|
||||
import { meldeUpload } from '../meldung.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 {
|
||||
PathError,
|
||||
checkPath,
|
||||
@@ -236,7 +236,7 @@ uploadRoutes.post('/upload', async (c) => {
|
||||
|
||||
if (!tokenAllows(token, path)) {
|
||||
return c.json(
|
||||
{ error: `dieser Token darf nur unter "${token.prefix}/" schreiben` },
|
||||
{ error: `dieser Token darf nur unter ${fesselText(token)} schreiben` },
|
||||
403,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { bytes } from '../format'
|
||||
import { run } from '../store'
|
||||
@@ -22,6 +22,7 @@ export function Ordner({
|
||||
onWahl,
|
||||
onAngelegt,
|
||||
onUmbenennen,
|
||||
onEntfernt,
|
||||
}: {
|
||||
daten: Folders | null
|
||||
gewaehlt: string
|
||||
@@ -29,12 +30,28 @@ export function Ordner({
|
||||
/** 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
|
||||
@@ -96,6 +113,30 @@ export function Ordner({
|
||||
</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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, FolderInput, Plus } from 'lucide-react'
|
||||
import { api, type Folders, type MediaItem } from '../api'
|
||||
import { run } from '../store'
|
||||
import { run, useStore } from '../store'
|
||||
import { Feld, Knopf } from './Bausteine'
|
||||
|
||||
/** Dateien in einen anderen Ordner legen.
|
||||
@@ -12,12 +12,17 @@ import { Feld, Knopf } from './Bausteine'
|
||||
* Zwischenspeicher. Das ist kein Fehler, sondern der Preis vorhersagbarer
|
||||
* Adressen; nur muss man es vorher wissen und nicht hinterher suchen. */
|
||||
export function Verschieben({
|
||||
dateien,
|
||||
ids,
|
||||
namen,
|
||||
ordner,
|
||||
onFertig,
|
||||
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
|
||||
onFertig: () => void
|
||||
onAbbruch: () => void
|
||||
@@ -25,27 +30,56 @@ export function Verschieben({
|
||||
const [ziel, setZiel] = useState('')
|
||||
const [neuerOrdner, setNeuerOrdner] = useState('')
|
||||
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 neuerPfad = ziel ? `${ziel}/${name}` : name
|
||||
|
||||
async function verschieben() {
|
||||
setLaeuft(true)
|
||||
const r = await run(
|
||||
() => api.verschieben(dateien.map((d) => d.id), ziel),
|
||||
(x) =>
|
||||
x.fehlgeschlagen === 0
|
||||
? `${x.verschoben} verschoben.`
|
||||
: `${x.verschoben} verschoben, ${x.fehlgeschlagen} nicht: ` +
|
||||
x.ergebnisse
|
||||
.filter((e) => !e.ok)
|
||||
.slice(0, 3)
|
||||
.map((e) => `${e.path} (${e.error})`)
|
||||
.join('; '),
|
||||
)
|
||||
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 (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() {
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
<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="text-schrift-leise line-through">{beispiel.path}</div>
|
||||
<div className="text-gut">{neuerPfad}</div>
|
||||
{dateien.length > 1 && (
|
||||
{ids.length > 1 && (
|
||||
<div className="mt-1 text-schrift-leise">
|
||||
… und {dateien.length - 1} weitere
|
||||
… und {ids.length - 1} weitere
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -141,7 +175,13 @@ export function Verschieben({
|
||||
art="haupt"
|
||||
wartet={laeuft}
|
||||
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>
|
||||
|
||||
+107
-31
@@ -60,12 +60,24 @@ export function Galerie() {
|
||||
const [daten, setDaten] = useState<MediaPage | null>(null)
|
||||
const [laedt, setLaedt] = useState(true)
|
||||
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)
|
||||
// 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.
|
||||
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 [ordnerUmbenennen, setOrdnerUmbenennen] = useState<string | null>(null)
|
||||
const { kopiert, kopieren } = useKopieren()
|
||||
@@ -197,23 +209,53 @@ export function Galerie() {
|
||||
async function loeschenAusfuehren() {
|
||||
if (!loeschen) return
|
||||
setLoescht(true)
|
||||
setLoeschFortschritt(0)
|
||||
|
||||
const ergebnis =
|
||||
loeschen.length === 1
|
||||
? await run(
|
||||
() => api.deleteMedia(loeschen[0]!.id),
|
||||
(r) => `${r.deleted} gelöscht.`,
|
||||
)
|
||||
: await run(
|
||||
() => api.deleteMediaBulk(loeschen.map((i) => i.id)),
|
||||
(r) =>
|
||||
r.count === loeschen.length
|
||||
? `${r.count} Dateien gelöscht.`
|
||||
: // Der Dienst überspringt, was er nicht findet.
|
||||
// Diese Lücke zu verschweigen wäre genau die
|
||||
// Sorte Rückmeldung, die hier nicht vorkommt.
|
||||
`${r.count} von ${loeschen.length} gelöscht — der Rest war schon weg.`,
|
||||
)
|
||||
// IN HAEPPCHEN, nicht in einem Zug.
|
||||
//
|
||||
// Der Dienst verschiebt jede Datei einzeln in den Papierkorb. Bei 3565
|
||||
// Bildern ist das eine Anfrage, die Minuten laeuft -- und die faellt
|
||||
// beim Proxy in die Zeitgrenze, lange bevor sie fertig ist. Dann waere
|
||||
// die Haelfte geloescht und die Oberflaeche meldete einen Fehler.
|
||||
//
|
||||
// 200 je Runde: gross genug, dass es nicht an der Anzahl der Anfragen
|
||||
// haengt, klein genug fuer eine Antwort in Sekunden. Und nach jeder
|
||||
// Runde steht im Knopf, wie weit es ist -- bei etwas, das eine Minute
|
||||
// dauert, ist Stillstand nicht von Haengen zu unterscheiden.
|
||||
const { note } = useStore.getState()
|
||||
const PORTION = 200
|
||||
const alle = loeschen.ids
|
||||
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)
|
||||
setLoeschen(null)
|
||||
@@ -243,6 +285,7 @@ export function Galerie() {
|
||||
daten={ordnerDaten}
|
||||
gewaehlt={ordner}
|
||||
onAngelegt={() => void ordnerLaden()}
|
||||
onEntfernt={() => void ordnerLaden()}
|
||||
onUmbenennen={setOrdnerUmbenennen}
|
||||
onWahl={(o) => {
|
||||
setOrdner(o)
|
||||
@@ -428,7 +471,12 @@ export function Galerie() {
|
||||
|
||||
{gewaehlt.size > 0 && (
|
||||
<Knopf
|
||||
onClick={() => setVerschieben(items.filter((i) => gewaehlt.has(i.id)))}
|
||||
onClick={() =>
|
||||
setVerschieben({
|
||||
ids: [...gewaehlt],
|
||||
namen: items.filter((i) => gewaehlt.has(i.id)),
|
||||
})
|
||||
}
|
||||
kind={
|
||||
<>
|
||||
<FolderInput size={15} />
|
||||
@@ -441,11 +489,23 @@ export function Galerie() {
|
||||
{gewaehlt.size > 0 && (
|
||||
<Knopf
|
||||
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={
|
||||
<>
|
||||
<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} />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setLoeschen([item])}
|
||||
onClick={() => setLoeschen({ ids: [item.id], namen: [item] })}
|
||||
className="rounded border border-rand px-2 py-1 text-schrift-leise
|
||||
hover:bg-schlecht/15 hover:text-schlecht"
|
||||
aria-label={`${item.path} löschen`}
|
||||
@@ -666,7 +726,7 @@ export function Galerie() {
|
||||
onSchliessen={() => setAnsehen(null)}
|
||||
onLoeschen={() => {
|
||||
setAnsehen(null)
|
||||
setLoeschen([item])
|
||||
setLoeschen({ ids: [item.id], namen: [item] })
|
||||
}}
|
||||
onUmbenennen={() => {
|
||||
setAnsehen(null)
|
||||
@@ -709,7 +769,8 @@ export function Galerie() {
|
||||
|
||||
{verschieben && (
|
||||
<Verschieben
|
||||
dateien={verschieben}
|
||||
ids={verschieben.ids}
|
||||
namen={verschieben.namen}
|
||||
ordner={ordnerDaten}
|
||||
onAbbruch={() => setVerschieben(null)}
|
||||
onFertig={() => {
|
||||
@@ -724,22 +785,37 @@ export function Galerie() {
|
||||
|
||||
<Nachfrage
|
||||
offen={loeschen !== null}
|
||||
titel={loeschen?.length === 1 ? 'Datei löschen?' : `${loeschen?.length} Dateien löschen?`}
|
||||
bestaetigung="Endgültig löschen"
|
||||
titel={
|
||||
loeschen?.ids.length === 1
|
||||
? 'Datei löschen?'
|
||||
: `${loeschen?.ids.length ?? 0} Dateien löschen?`
|
||||
}
|
||||
bestaetigung="In den Papierkorb"
|
||||
wartet={loescht}
|
||||
onNein={() => setLoeschen(null)}
|
||||
onJa={() => void loeschenAusfuehren()}
|
||||
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">
|
||||
Die Datei wird von der Platte entfernt. Es gibt keine Sicherung, aus
|
||||
der sie zurückkommt.
|
||||
Verschiebt in den <b>Papierkorb</b> — dort liegt alles noch 30 Tage und
|
||||
lässt sich zurückholen. Erst danach ist es endgültig weg.
|
||||
</p>
|
||||
<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 && loeschen.length > 12 && (
|
||||
{loeschen?.namen.slice(0, 12).map((i) => <li key={i.id}>{i.path}</li>)}
|
||||
{/* 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">
|
||||
… und {loeschen.length - 12} weitere
|
||||
… und {loeschen.ids.length - loeschen.namen.slice(0, 12).length} weitere
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
@@ -98,10 +98,10 @@ export function Tokens() {
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Feld
|
||||
label="Präfix (optional)"
|
||||
label="Ordner (optional)"
|
||||
value={prefix}
|
||||
placeholder="vehicles"
|
||||
hinweis="Der Token darf dann nur darunter schreiben."
|
||||
placeholder="vehicles, items"
|
||||
hinweis="Nur unter diesen Ordnern darf der Token schreiben. Mehrere durch Komma. Leer = überall."
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -237,7 +237,7 @@ export function Tokens() {
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-schrift-leise">
|
||||
<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">Grenzen</th>
|
||||
{/* Was mit dem Token wirklich abgelegt wurde.
|
||||
@@ -266,7 +266,12 @@ export function Tokens() {
|
||||
)}
|
||||
</td>
|
||||
<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 className="py-2">
|
||||
{t.can_delete ? (
|
||||
|
||||
Reference in New Issue
Block a user