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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
Reference in New Issue
Block a user