From 6a497138215fa51c393294494d60aa437d69a5f0 Mon Sep 17 00:00:00 2001
From: D4rkst3r
Date: Wed, 12 Aug 2026 08:03:04 +0200
Subject: [PATCH] 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
---
server/ui/src/api.ts | 36 +++
server/ui/src/components/ErsetzenKarte.tsx | 232 +++++++++++++++++++
server/ui/src/components/FehlendeKarte.tsx | 174 ++++++++++++++
server/ui/src/components/MitnehmenKarte.tsx | 144 ++++++++++++
server/ui/src/components/TokenBearbeiten.tsx | 13 ++
server/ui/src/pages/Einstellungen.tsx | 3 +
server/ui/src/pages/Speicher.tsx | 16 +-
server/ui/src/pages/Tokens.tsx | 13 ++
8 files changed, 630 insertions(+), 1 deletion(-)
create mode 100644 server/ui/src/components/ErsetzenKarte.tsx
create mode 100644 server/ui/src/components/FehlendeKarte.tsx
create mode 100644 server/ui/src/components/MitnehmenKarte.tsx
diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts
index d95256d..d9547e1 100644
--- a/server/ui/src/api.ts
+++ b/server/ui/src/api.ts
@@ -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, 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
diff --git a/server/ui/src/components/ErsetzenKarte.tsx b/server/ui/src/components/ErsetzenKarte.tsx
new file mode 100644
index 0000000..060374b
--- /dev/null
+++ b/server/ui/src/components/ErsetzenKarte.tsx
@@ -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>
+
+/** 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(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 (
+
+
+ Für den Fall, dass sich eine Namenskonvention ändert oder ein Ordner anders heißen
+ soll. Erst wird gezeigt, was passieren würde{' '}
+ — angefasst wird nichts, bevor du es gelesen hast.
+
+
+
+ Die betroffenen Dateien wurden zusammen{' '}
+ {vorschau.abrufeGesamt}× geholt. Jede alte Adresse
+ gibt danach 404 — und keine davon meldet sich von selbst.
+
+
+
+
+ {suchen} wird zu{' '}
+ {ersetzen || '(nichts)'}, in{' '}
+ {vorschau?.planGesamt} Datei(en).{' '}
+
+ Alle bisherigen Adressen dieser Dateien geben danach 404.
+ {' '}
+ 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.
+
+
+ }
+ />
+
+ )
+}
diff --git a/server/ui/src/components/FehlendeKarte.tsx b/server/ui/src/components/FehlendeKarte.tsx
new file mode 100644
index 0000000..d2a6e95
--- /dev/null
+++ b/server/ui/src/components/FehlendeKarte.tsx
@@ -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([])
+ const [muster, setMuster] = useState('vehicles/{model}.webp')
+ const [namen, setNamen] = useState('')
+ const [laeuft, setLaeuft] = useState(false)
+ const [ergebnis, setErgebnis] = useState
+ > | 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 (
+
+
+ Eine Liste von Namen hineingeben — Fahrzeugmodelle, Item-Namen — und beantwortet
+ bekommen, wozu kein Bild daliegt. Und
+ umgekehrt: was daliegt, ohne in der Liste zu stehen.
+
+ )
+}
diff --git a/server/ui/src/components/MitnehmenKarte.tsx b/server/ui/src/components/MitnehmenKarte.tsx
new file mode 100644
index 0000000..26805f2
--- /dev/null
+++ b/server/ui/src/components/MitnehmenKarte.tsx
@@ -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(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 }
+ 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, 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 (
+
+
+ Alles, was unter Einstellungen steht, als eine Datei — für einen Umzug oder als
+ Notiz daneben. Ohne Geheimnisse: das
+ Discord-Client-Geheimnis und das Nextcloud-Passwort bleiben hier und sind nach
+ einem Einspielen von Hand nachzutragen.
+
+ Beim Einspielen überschreiben leere Werte nichts — eine Datei ohne
+ Geheimnisse darf ein vorhandenes Passwort nicht löschen. Und ohne den Haken bleibt
+ stehen, was schon dasteht.
+
+
+
+ Die Datei enthält dann das Discord-Client-Geheimnis und
+ das Nextcloud-Passwort 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.
+
+
+ Der Download wird im Verwaltungs-Verlauf vermerkt.
+
+
+ }
+ />
+
+ )
+}
diff --git a/server/ui/src/components/TokenBearbeiten.tsx b/server/ui/src/components/TokenBearbeiten.tsx
index 95699cb..ffa4ab4 100644
--- a/server/ui/src/components/TokenBearbeiten.tsx
+++ b/server/ui/src/components/TokenBearbeiten.tsx
@@ -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(
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({
/>
+ setMaxKante(e.target.value)}
+ />
+
+
+
+ setOrdnerDaten(null)} />
+
([])
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)}
/>
+ setMaxKante(e.target.value)}
+ />