feat: die Oberflaeche zu den vier Werkzeugen

Bisher liessen sie sich nur ueber die API benutzen. Jetzt stehen sie dort, wo
man sie sucht.

SPEICHER bekommt zwei Karten:

"Wozu fehlt ein Bild?" -- Muster und Namensliste hinein, beide Richtungen
heraus. Das Muster kommt aus den ADRESSVORLAGEN und wird als Knopfreihe
angeboten, damit hier nicht dieselbe Zeichenkette ein zweites Mal getippt wird.

"Suchen und Ersetzen in Pfaden" -- und dort gibt es KEINEN Knopf, der es sofort
tut. Erst zeigen, dann fragen, dann machen. Die Vorschau nennt die Zahl, die
die Entscheidung traegt: wie oft die betroffenen Dateien zusammen geholt
wurden. Null heisst, es schaut niemand zu; viertausend heisst, es schaut jemand
zu. Gibt es auch nur ein Problem, verschwindet der Ausfuehren-Knopf ganz --
nicht ausgegraut, sondern weg, denn ein Problem verhindert ohnehin alles.

EINSTELLUNGEN bekommt "Einstellungen mitnehmen". Herunterladen ist der
harmlose Knopf; "mit Geheimnissen" ist rot und fragt nach, mit dem Satz, dass
die Datei das Discord-Geheimnis und das Nextcloud-Passwort im Klartext traegt
und der Download im Verwaltungs-Verlauf steht.

TOKEN bekommen das Feld "Bilder verkleinern auf" -- im Anlege-Formular und im
Bearbeiten-Dialog, mit dem Hinweis, dass kleinere Bilder NICHT aufgeblasen
werden. In der Liste steht es als "max 512 px" neben den anderen Grenzen.

Zwei eigene Stolperer beim Bauen, beide vom Uebersetzer gefangen: ein Patch
brach vor dem Schreiben ab, sodass die Haelfte der API-Aufrufe fehlte -- und in
FehlendeKarte blieb ein `note` stehen, das niemand mehr benutzte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 08:03:04 +02:00
co-authored by Claude Opus 5
parent 381feade4e
commit 6a49713821
8 changed files with 630 additions and 1 deletions
+36
View File
@@ -178,6 +178,8 @@ export type TokenRow = {
max_bytes: number | null
/** Wieviel dieser Token INSGESAMT halten darf. Null = unbegrenzt. */
quota_bytes: number | null
/** Groesste Kantenlaenge fuer Bilder. Null = ablegen wie es kommt. */
max_kante: number | null
/** Erlaubte Arten, mit Komma. Leer = alle. */
arten: string
expires_at: number | null
@@ -508,6 +510,38 @@ export const api = {
{ aktion },
),
/** Wozu fehlt ein Bild — und was liegt da, ohne in der Liste zu stehen. */
fehlende: (muster: string, namen: string) =>
post<{
muster: string
geprueft: number
da: number
fehlt: { name: string; pfad: string }[]
ueberzaehlig: string[]
ueberzaehligGesamt: number
unbrauchbar: { name: string; grund: string }[]
}>('/api/dash/fehlende', { muster, namen }),
/** Ohne `ausfuehren` wird nur gerechnet und gezeigt — angefasst wird nichts. */
ersetzen: (suchen: string, ersetzen: string, ordner: string, ausfuehren = false) =>
post<{
vorschau?: true
treffer?: number
plan?: { alt: string; neu: string; abrufe: number }[]
planGesamt?: number
probleme?: { alt: string; grund: string }[]
abrufeGesamt?: number
umbenannt?: number
gescheitert?: { alt: string; grund: string }[]
beispiele?: { alt: string; neu: string }[]
}>('/api/dash/media/ersetzen', { suchen, ersetzen, ordner, ausfuehren }),
einstellungenImport: (werte: Record<string, string>, ueberschreiben: boolean) =>
post<{ gesetzt: string[]; uebersprungen: string[] }>('/api/dash/einstellungen/import', {
werte,
ueberschreiben,
}),
verwaltung: (limit = 100, offset = 0) =>
get<{ items: VerwaltungRow[]; total: number; limit: number; offset: number }>(
`/api/dash/verwaltung?limit=${limit}&offset=${offset}`,
@@ -534,6 +568,7 @@ export const api = {
canDelete?: boolean
maxMb?: string | null
quotaMb?: string | null
maxKante?: string | null
arten?: string[] | null
gueltigTage?: string | null
},
@@ -573,6 +608,7 @@ export const api = {
prefix: string
canDelete: boolean
maxMb?: string
maxKante?: string
quotaMb?: string
arten?: string[]
gueltigTage?: string
+232
View File
@@ -0,0 +1,232 @@
import { useState } from 'react'
import { AlertTriangle, Eye, Replace } from 'lucide-react'
import { api, type Folders } from '../api'
import { run } from '../store'
import { Karte, Knopf, Nachfrage } from './Bausteine'
type Vorschau = Awaited<ReturnType<typeof api.ersetzen>>
/** Suchen und Ersetzen über viele Pfade.
*
* DIE VORSCHAU IST DER EIGENTLICHE TEIL, nicht die Bequemlichkeit dahinter.
* Was hier passiert, ist dieselbe Warnung wie beim Umbenennen eines Ordners —
* **jede alte Adresse gibt danach 404** —, nur mal tausend. Wer das ohne
* Gegenlesen auslöst, hat keine Gelegenheit mehr, es zu bemerken.
*
* Deshalb gibt es hier keinen Knopf, der es sofort tut. Erst zeigen, dann
* fragen, dann machen. */
export function ErsetzenKarte({
ordnerDaten,
fertig,
}: {
ordnerDaten: Folders | null
fertig: () => void
}) {
const [suchen, setSuchen] = useState('')
const [ersetzen, setErsetzen] = useState('')
const [ordner, setOrdner] = useState('')
const [vorschau, setVorschau] = useState<Vorschau | null>(null)
const [laeuft, setLaeuft] = useState(false)
const [fragt, setFragt] = useState(false)
const bereit = suchen.trim().length > 0
const machbar =
vorschau?.vorschau === true &&
(vorschau.probleme?.length ?? 0) === 0 &&
(vorschau.planGesamt ?? 0) > 0
return (
<Karte titel="Suchen und Ersetzen in Pfaden">
<p className="mb-3 text-xs text-schrift-leise">
Für den Fall, dass sich eine Namenskonvention ändert oder ein Ordner anders heißen
soll. <strong className="text-schrift">Erst wird gezeigt, was passieren würde</strong>{' '}
angefasst wird nichts, bevor du es gelesen hast.
</p>
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Suchen
</span>
<input
value={suchen}
onChange={(e) => {
setSuchen(e.target.value)
setVorschau(null)
}}
placeholder="weapon_"
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5
font-mono text-sm text-schrift placeholder:text-schrift-leise/60
focus:border-akzent focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Ersetzen durch
</span>
<input
value={ersetzen}
onChange={(e) => {
setErsetzen(e.target.value)
setVorschau(null)
}}
placeholder="leer = entfernen"
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5
font-mono text-sm text-schrift placeholder:text-schrift-leise/60
focus:border-akzent focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Nur im Ordner
</span>
<select
value={ordner}
onChange={(e) => {
setOrdner(e.target.value)
setVorschau(null)
}}
className="w-full rounded-md border border-rand bg-grund px-2 py-1.5
font-mono text-xs text-schrift focus:border-akzent focus:outline-none"
>
<option value="">(überall)</option>
{(ordnerDaten?.items ?? []).map((o) => (
<option key={o.folder} value={o.folder}>
{' '.repeat(o.tiefe * 2)}
{o.folder.split('/').pop()}
</option>
))}
</select>
</label>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Knopf
wartet={laeuft && !fragt}
disabled={!bereit}
onClick={async () => {
setLaeuft(true)
const r = await run(
() => api.ersetzen(suchen, ersetzen, ordner, false),
(x) =>
(x.probleme?.length ?? 0) > 0
? `${x.planGesamt} möglich, ${x.probleme?.length} nicht — siehe unten.`
: `${x.planGesamt} Datei(en) würden umbenannt.`,
)
setLaeuft(false)
setVorschau(r)
}}
kind={
<>
<Eye size={15} />
Zeigen, was passieren würde
</>
}
/>
{machbar && (
<Knopf
art="gefahr"
onClick={() => setFragt(true)}
kind={
<>
<Replace size={15} />
{vorschau?.planGesamt} umbenennen
</>
}
/>
)}
</div>
{vorschau?.vorschau && (
<div className="mt-4 flex flex-col gap-3 border-t border-rand pt-3">
<p className="text-sm">
<strong>{vorschau.treffer}</strong> Treffer ·{' '}
<strong className="text-akzent">{vorschau.planGesamt}</strong> umbenennbar ·{' '}
<strong className={vorschau.probleme?.length ? 'text-schlecht' : ''}>
{vorschau.probleme?.length ?? 0}
</strong>{' '}
Probleme
</p>
{(vorschau.abrufeGesamt ?? 0) > 0 && (
<p className="flex items-start gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-2 text-xs text-warn">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>
Die betroffenen Dateien wurden zusammen{' '}
<strong>{vorschau.abrufeGesamt}×</strong> geholt. Jede alte Adresse
gibt danach 404 und keine davon meldet sich von selbst.
</span>
</p>
)}
{(vorschau.probleme?.length ?? 0) > 0 && (
<div>
<p className="mb-1 text-xs font-medium text-schlecht">
Das geht nicht und deshalb wird gar nichts angefasst
</p>
<pre className="max-h-40 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-[11px]">
{vorschau.probleme?.map((p) => `${p.alt}\n → ${p.grund}`).join('\n')}
</pre>
</div>
)}
{(vorschau.plan?.length ?? 0) > 0 && (
<div>
<p className="mb-1 text-xs font-medium text-schrift-leise">
So sähe es aus
{(vorschau.planGesamt ?? 0) > (vorschau.plan?.length ?? 0) &&
` — erste ${vorschau.plan?.length} von ${vorschau.planGesamt}`}
</p>
<pre className="max-h-56 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-[11px] leading-relaxed">
{vorschau.plan?.map((p) => `${p.alt}\n → ${p.neu}`).join('\n')}
</pre>
</div>
)}
</div>
)}
<Nachfrage
offen={fragt}
titel={`${vorschau?.planGesamt} Dateien umbenennen?`}
bestaetigung="Umbenennen"
wartet={laeuft}
onNein={() => setFragt(false)}
onJa={async () => {
setLaeuft(true)
const r = await run(
() => api.ersetzen(suchen, ersetzen, ordner, true),
(x) =>
`${x.umbenannt} umbenannt` +
((x.gescheitert?.length ?? 0) > 0
? `, ${x.gescheitert?.length} nicht`
: '.'),
)
setLaeuft(false)
setFragt(false)
if (r) {
setVorschau(null)
setSuchen('')
setErsetzen('')
fertig()
}
}}
text={
<p className="flex items-start gap-2">
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-schlecht" />
<span>
<code className="font-mono">{suchen}</code> wird zu{' '}
<code className="font-mono">{ersetzen || '(nichts)'}</code>, in{' '}
{vorschau?.planGesamt} Datei(en).{' '}
<strong className="text-schrift">
Alle bisherigen Adressen dieser Dateien geben danach 404.
</strong>{' '}
Rückgängig geht das nur, indem du es andersherum noch einmal machst
und das findet die Dateien nur, solange niemand sonst etwas geändert
hat.
</span>
</p>
}
/>
</Karte>
)
}
+174
View File
@@ -0,0 +1,174 @@
import { useEffect, useState } from 'react'
import { Check, ListChecks } from 'lucide-react'
import { api, type Vorlage } from '../api'
import { run } from '../store'
import { Karte, Knopf } from './Bausteine'
/** Wozu fehlt ein Bild?
*
* DIE FRAGE, DIE BISHER NIEMAND BEANTWORTET HAT. Das Fotostudio weiß, was es
* aufgenommen hat. Dieser Dienst weiß, was liegt. Die Differenz rechnet
* keiner aus — und deshalb fällt „das Handy zeigt bei manchen Autos kein Bild"
* erst im Spiel auf.
*
* Das Muster kommt aus den **Adressvorlagen**, damit hier nicht dieselbe
* Zeichenkette ein zweites Mal getippt wird. Wer keine angelegt hat, tippt
* sie — aber der Vorschlag steht daneben. */
export function FehlendeKarte() {
const [vorlagen, setVorlagen] = useState<Vorlage[]>([])
const [muster, setMuster] = useState('vehicles/{model}.webp')
const [namen, setNamen] = useState('')
const [laeuft, setLaeuft] = useState(false)
const [ergebnis, setErgebnis] = useState<Awaited<
ReturnType<typeof api.fehlende>
> | null>(null)
useEffect(() => {
api.einstellungen()
.then((e) => {
setVorlagen(e.vorlagen)
if (e.vorlagen[0]) setMuster(e.vorlagen[0].muster)
})
.catch(() => {
/* dann eben ohne Vorschläge — das Feld ist trotzdem benutzbar */
})
}, [])
const anzahl = namen.split(/[\r\n,;]+/).filter((n) => n.trim()).length
return (
<Karte titel="Wozu fehlt ein Bild?">
<p className="mb-3 text-xs text-schrift-leise">
Eine Liste von Namen hineingeben Fahrzeugmodelle, Item-Namen und beantwortet
bekommen, wozu <strong className="text-schrift">kein Bild</strong> daliegt. Und
umgekehrt: was daliegt, ohne in der Liste zu stehen.
</p>
<div className="flex flex-col gap-3">
<label className="block">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Muster genau ein Platzhalter
</span>
<input
value={muster}
onChange={(e) => setMuster(e.target.value)}
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5
font-mono text-sm text-schrift focus:border-akzent focus:outline-none"
/>
{vorlagen.length > 0 && (
<span className="mt-1 flex flex-wrap gap-1">
{vorlagen.map((v) => (
<button
key={v.muster}
onClick={() => setMuster(v.muster)}
className="rounded border border-rand px-1.5 py-0.5 font-mono
text-[11px] text-schrift-leise hover:bg-flaeche-hoch
hover:text-schrift"
>
{v.name}
</button>
))}
</span>
)}
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-schrift-leise">
Namen einer je Zeile {anzahl > 0 && `(${anzahl})`}
</span>
<textarea
value={namen}
onChange={(e) => setNamen(e.target.value)}
rows={5}
placeholder={'adder\nzentorno\nkuruma'}
className="w-full rounded-md border border-rand bg-grund px-3 py-1.5
font-mono text-xs text-schrift placeholder:text-schrift-leise/60
focus:border-akzent focus:outline-none"
/>
</label>
<Knopf
art="haupt"
wartet={laeuft}
disabled={!muster.trim() || anzahl === 0}
className="self-start"
onClick={async () => {
setLaeuft(true)
const r = await run(
() => api.fehlende(muster.trim(), namen),
(x) =>
x.fehlt.length === 0
? `Alle ${x.geprueft} sind da.`
: `${x.fehlt.length} von ${x.geprueft} fehlen.`,
)
setLaeuft(false)
if (r) setErgebnis(r)
}}
kind={
<>
<ListChecks size={15} />
Nachsehen
</>
}
/>
</div>
{ergebnis && (
<div className="mt-4 flex flex-col gap-3 border-t border-rand pt-3">
<p className="text-sm">
<strong className="text-gut">{ergebnis.da}</strong> da ·{' '}
<strong className={ergebnis.fehlt.length ? 'text-schlecht' : ''}>
{ergebnis.fehlt.length}
</strong>{' '}
fehlen · <strong>{ergebnis.ueberzaehligGesamt}</strong> überzählig
</p>
{ergebnis.fehlt.length > 0 && (
<Liste
titel="Fehlt — dafür gibt es kein Bild"
farbe="text-schlecht"
zeilen={ergebnis.fehlt.map((f) => `${f.name}${f.pfad}`)}
/>
)}
{ergebnis.ueberzaehligGesamt > 0 && (
<Liste
titel={`Liegt da, steht aber nicht in der Liste${
ergebnis.ueberzaehligGesamt > ergebnis.ueberzaehlig.length
? ` — erste ${ergebnis.ueberzaehlig.length} von ${ergebnis.ueberzaehligGesamt}`
: ''
}`}
farbe="text-warn"
zeilen={ergebnis.ueberzaehlig}
/>
)}
{ergebnis.unbrauchbar.length > 0 && (
<Liste
titel="Als Pfad nicht zulässig"
farbe="text-schrift-leise"
zeilen={ergebnis.unbrauchbar.map((u: { name: string; grund: string }) => `${u.name}${u.grund}`)}
/>
)}
{ergebnis.fehlt.length === 0 && ergebnis.ueberzaehligGesamt === 0 && (
<p className="flex items-center gap-2 text-sm text-gut">
<Check size={15} /> Liste und Bestand stimmen überein.
</p>
)}
</div>
)}
</Karte>
)
}
function Liste({ titel, farbe, zeilen }: { titel: string; farbe: string; zeilen: string[] }) {
return (
<div>
<p className={`mb-1 text-xs font-medium ${farbe}`}>{titel}</p>
<pre className="max-h-48 overflow-auto rounded border border-rand bg-grund p-2 font-mono text-[11px] leading-relaxed">
{zeilen.join('\n')}
</pre>
</div>
)
}
+144
View File
@@ -0,0 +1,144 @@
import { useRef, useState } from 'react'
import { AlertTriangle, Download, Upload } from 'lucide-react'
import { api } from '../api'
import { run, useStore } from '../store'
import { Karte, Knopf, Nachfrage } from './Bausteine'
/** Einstellungen mitnehmen.
*
* Discord-Zugang, Sicherungsziel, Adressvorlagen, Meldungsanlässe, Marke —
* alles steht in einer Tabelle und wäre beim Neuaufsetzen von Hand
* nachzutragen. Eine Stunde Klickerei, bei der man genau die eine Zeile
* vergisst, die man erst drei Wochen später braucht.
*
* GEHEIMNISSE GEHEN STANDARDMÄSSIG NICHT MIT. Eine Datei, die man „mal eben"
* exportiert und in einen Chat zieht, ist genau der Weg, auf dem ein Passwort
* abhandenkommt. Wer sie braucht, sagt es ausdrücklich — und der Hinweis steht
* dann **in** der Datei, nicht nur hier auf dem Bildschirm, von dem er beim
* Weiterreichen abfällt. */
export function MitnehmenKarte() {
const note = useStore((s) => s.note)
const [fragt, setFragt] = useState(false)
const [ueberschreiben, setUeberschreiben] = useState(false)
const feld = useRef<HTMLInputElement>(null)
function holen(mitGeheim: boolean) {
// Über einen Link und nicht über fetch: so bekommt die Datei den Namen
// aus dem Content-Disposition und landet im Download-Ordner statt im
// Speicher des Browsers.
const a = document.createElement('a')
a.href = `/api/dash/einstellungen/export${mitGeheim ? '?geheim=1' : ''}`
a.rel = 'noreferrer'
a.click()
setFragt(false)
}
async function einlesen(datei: File) {
let daten: { werte?: Record<string, string> }
try {
daten = JSON.parse(await datei.text())
} catch {
note('fehler', 'Das ist keine gültige JSON-Datei.')
return
}
if (!daten.werte || typeof daten.werte !== 'object') {
note('fehler', 'In der Datei fehlt das Feld „werte" — ist das ein Export von hier?')
return
}
await run(
() => api.einstellungenImport(daten.werte as Record<string, string>, ueberschreiben),
(r) =>
r.gesetzt.length === 0
? `Nichts gesetzt — ${r.uebersprungen.length} übersprungen. ${
ueberschreiben ? '' : 'Vorhandenes bleibt stehen, solange der Haken aus ist.'
}`
: `${r.gesetzt.length} gesetzt, ${r.uebersprungen.length} übersprungen.`,
)
if (feld.current) feld.current.value = ''
}
return (
<Karte titel="Einstellungen mitnehmen">
<p className="mb-3 text-xs text-schrift-leise">
Alles, was unter Einstellungen steht, als eine Datei für einen Umzug oder als
Notiz daneben. <strong className="text-schrift">Ohne Geheimnisse</strong>: das
Discord-Client-Geheimnis und das Nextcloud-Passwort bleiben hier und sind nach
einem Einspielen von Hand nachzutragen.
</p>
<div className="flex flex-wrap items-center gap-2">
<Knopf
onClick={() => holen(false)}
kind={
<>
<Download size={15} />
Herunterladen
</>
}
/>
<Knopf
art="gefahr"
onClick={() => setFragt(true)}
kind="mit Geheimnissen"
/>
<span className="mx-1 h-5 w-px bg-rand" />
<Knopf
onClick={() => feld.current?.click()}
kind={
<>
<Upload size={15} />
Einspielen
</>
}
/>
<label className="flex items-center gap-1.5 text-xs text-schrift-leise">
<input
type="checkbox"
checked={ueberschreiben}
onChange={(e) => setUeberschreiben(e.target.checked)}
className="accent-akzent"
/>
Vorhandenes überschreiben
</label>
<input
ref={feld}
type="file"
accept="application/json,.json"
hidden
onChange={(e) => e.target.files?.[0] && void einlesen(e.target.files[0])}
/>
</div>
<p className="mt-2 text-[11px] text-schrift-leise">
Beim Einspielen überschreiben <strong>leere Werte nichts</strong> eine Datei ohne
Geheimnisse darf ein vorhandenes Passwort nicht löschen. Und ohne den Haken bleibt
stehen, was schon dasteht.
</p>
<Nachfrage
offen={fragt}
titel="Export mit Geheimnissen?"
bestaetigung="Trotzdem herunterladen"
onNein={() => setFragt(false)}
onJa={() => holen(true)}
text={
<p className="flex items-start gap-2">
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-schlecht" />
<span>
Die Datei enthält dann das <strong>Discord-Client-Geheimnis</strong> und
das <strong>Nextcloud-Passwort</strong> im Klartext. Nicht in einen Chat
ziehen, nicht in ein Repository legen. Dass sie es enthält, steht auch in
der Datei selbst dieser Hinweis hier fällt beim Weiterreichen ab.
<br />
<br />
Der Download wird im Verwaltungs-Verlauf vermerkt.
</span>
</p>
}
/>
</Karte>
)
}
@@ -42,6 +42,7 @@ export function TokenBearbeiten({
const [quotaMb, setQuotaMb] = useState(
token.quota_bytes ? String(Math.round(token.quota_bytes / 1048576)) : '',
)
const [maxKante, setMaxKante] = useState(token.max_kante ? String(token.max_kante) : '')
const [arten, setArten] = useState<string[]>(
token.arten ? token.arten.split(',').map((a) => a.trim()).filter(Boolean) : [],
)
@@ -69,6 +70,7 @@ export function TokenBearbeiten({
// gesetzte Grenze nie wieder loswerden.
maxMb: maxMb.trim() || null,
quotaMb: quotaMb.trim() || null,
maxKante: maxKante.trim() || null,
arten,
// Nur schicken, wenn etwas dasteht: ein leeres Feld
// soll den Ablauf NICHT löschen, sondern ihn
@@ -119,6 +121,17 @@ export function TokenBearbeiten({
/>
</div>
<Feld
label="Bilder verkleinern auf (Pixel)"
type="number"
min={16}
step="64"
value={maxKante}
placeholder="leer = ablegen wie es kommt"
hinweis="Größte Kantenlänge. Kleinere Bilder werden nicht aufgeblasen."
onChange={(e) => setMaxKante(e.target.value)}
/>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
+3
View File
@@ -8,6 +8,7 @@ import { DiscordKarte } from '../components/DiscordKarte'
import { VorlagenKarte } from '../components/VorlagenKarte'
import { FreigabenKarte } from '../components/FreigabenKarte'
import { BenutzerKarte } from '../components/BenutzerKarte'
import { MitnehmenKarte } from '../components/MitnehmenKarte'
import { MeldungenKarte } from '../components/MeldungenKarte'
/** Alles, was sich einstellen lässt, an einer Stelle.
@@ -34,6 +35,8 @@ export function Einstellungen() {
<FreigabenKarte />
<BenutzerKarte />
<MitnehmenKarte />
<SicherungKarte />
<MeldungenKarte />
<DiscordKarte />
+15 -1
View File
@@ -14,8 +14,11 @@ import { api, type Stats } from '../api'
import { run, useStore } from '../store'
import { bytes } from '../format'
import { Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
import { type Verwaiste } from '../api'
import { type Folders, type Verwaiste } from '../api'
import { SpeicherKarten } from '../components/SpeicherKarten'
import { FehlendeKarte } from '../components/FehlendeKarte'
import { ErsetzenKarte } from '../components/ErsetzenKarte'
import { api as apiFolders } from '../api'
export function Speicher() {
const note = useStore((s) => s.note)
@@ -24,6 +27,13 @@ export function Speicher() {
const [zieht, setZieht] = useState(false)
const [spart, setSpart] = useState(false)
const [fortschritt, setFortschritt] = useState({ thumbs: '', webp: '' })
// Die Ordnerliste brauchen zwei Karten hier — geholt wird sie einmal.
const [ordnerDaten, setOrdnerDaten] = useState<Folders | null>(null)
useEffect(() => {
apiFolders.folders().then(setOrdnerDaten).catch(() => {
/* die Auswahl bleibt dann bei "(überall)" — kein Grund zu lärmen */
})
}, [])
const [verwaiste, setVerwaiste] = useState<Verwaiste | null>(null)
const [sucht, setSucht] = useState(false)
const [handelt, setHandelt] = useState(false)
@@ -136,6 +146,10 @@ export function Speicher() {
</p>
</Karte>
<FehlendeKarte />
<ErsetzenKarte ordnerDaten={ordnerDaten} fertig={() => setOrdnerDaten(null)} />
<Karte titel="Wartung">
<div className="flex flex-wrap items-center gap-3">
<Knopf
+13
View File
@@ -16,6 +16,7 @@ export function Tokens() {
const [darfLoeschen, setDarfLoeschen] = useState(false)
const [maxMb, setMaxMb] = useState('')
const [quotaMb, setQuotaMb] = useState('')
const [maxKante, setMaxKante] = useState('')
const [gueltigTage, setGueltigTage] = useState('')
const [arten, setArten] = useState<string[]>([])
const [legtAn, setLegtAn] = useState(false)
@@ -56,6 +57,7 @@ export function Tokens() {
canDelete: darfLoeschen,
maxMb: maxMb.trim(),
quotaMb: quotaMb.trim(),
maxKante: maxKante.trim(),
arten,
gueltigTage: gueltigTage.trim(),
}),
@@ -125,6 +127,16 @@ export function Tokens() {
hinweis="Die Grenze links gilt je Datei. Diese hier gilt für alles zusammen — ohne sie kann ein Token die Platte füllen, es braucht nur genug kleine Dateien."
onChange={(e) => setQuotaMb(e.target.value)}
/>
<Feld
label="Bilder verkleinern auf (Pixel, optional)"
type="number"
min={16}
step="64"
value={maxKante}
placeholder="leer = ablegen wie es kommt"
hinweis="Größte Kantenlänge. Kleinere Bilder werden NICHT aufgeblasen. Spart Platz und Ladezeit — als Schutz braucht es das nicht."
onChange={(e) => setMaxKante(e.target.value)}
/>
<Feld
label="Gültig für (Tage, optional)"
type="number"
@@ -273,6 +285,7 @@ export function Tokens() {
t.quota_bytes
? `${bytes(t.bytes)} von ${bytes(t.quota_bytes)}`
: null,
t.max_kante ? `max ${t.max_kante} px` : null,
t.arten ? t.arten.replaceAll(',', ', ') : null,
t.expires_at
? `bis ${zeit(t.expires_at).slice(0, 10)}`