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)}
+ />