feat: das Dashboard -- und die eine Regel steht im Code, nicht im Vorsatz

Schritt 4. React 18, TypeScript, Vite 5, Tailwind 4 (CSS-first), Lucide,
Zustand; kein Konsta. Quelle in server/ui, Build nach server/web (gitignored),
im Abbild von einer eigenen Docker-Stufe gebaut.

JEDER KNOPF GIBT RUECKMELDUNG -- und zwar erzwungen, nicht vorgenommen:

- api.ts haelt den EINZIGEN fetch und GENAU EINE Fehlerklasse. Der
  Fivemanage-Fehler war, zwei zu haben: geworfen wurde `new Error`, geprueft
  auf `instanceof ApiError`, und damit verschwand jeder Fehlschlag lautlos.
- store.ts haelt run(): Aktion rein, Erfolgstext rein, und es meldet bei
  Erfolg diesen und bei Fehlschlag den Text DES DIENSTES. Wer darueber geht,
  kann keinen stillen Knopf bauen.
- Jeder wartende Knopf ist gesperrt und zeigt einen Kreisel. Der zweite Klick
  eine Sekunde spaeter war der Weg zu neunzehn gleichnamigen Organisationen.
- Fehlermeldungen bleiben stehen, bis jemand sie wegklickt. Ein Fehler, der
  von allein verschwindet, ist einer, den niemand gelesen hat.

@vitejs/plugin-react ist auf ^4 festgenagelt: die 6 verlangt vite ^8. In den
peerDependencies nachgesehen -- Vite 5.4.21 liegt in der Schnittmenge von
plugin-react@4 und @tailwindcss/vite@4.

Am laufenden Container nachgemessen: / kommt als text/html, assets/*.js als
text/javascript, *.css als text/css (die MIME-Falle aus Schritt 1 traegt), und
/tokens, /speicher, /konto liefern dieselbe index.html -- die SPA-Rueckfall-
route tut, wofuer sie gebaut wurde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 17:38:38 +02:00
co-authored by Claude Opus 5
parent 65828b526c
commit 6c8ead8e15
22 changed files with 4644 additions and 15 deletions
+77
View File
@@ -0,0 +1,77 @@
import { useState, type FormEvent } from 'react'
import { HardDrive } from 'lucide-react'
import { useStore } from '../store'
import { Feld, Knopf } from '../components/Bausteine'
export function Anmelden() {
const login = useStore((s) => s.login)
const note = useStore((s) => s.note)
const [name, setName] = useState('admin')
const [passwort, setPasswort] = useState('')
const [wartet, setWartet] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
async function absenden(e: FormEvent) {
e.preventDefault()
setFehler(null)
setWartet(true)
try {
await login(name, passwort)
} catch (err) {
// Der Fehler steht AM FORMULAR und nicht nur in einer Meldung, die
// nach vier Sekunden geht: hier schaut man hin, wenn etwas nicht
// klappt. Der Text kommt vom Dienst ("Benutzername oder Passwort
// stimmt nicht"), nicht von hier.
const text = err instanceof Error ? err.message : 'Unbekannter Fehler'
setFehler(text)
note('fehler', text)
} finally {
setWartet(false)
}
}
return (
<div className="flex min-h-full items-center justify-center p-4">
<form
onSubmit={absenden}
className="w-full max-w-sm rounded-lg border border-rand bg-flaeche p-6"
>
<div className="mb-6 flex items-center gap-2">
<HardDrive size={20} className="text-akzent" />
<h1 className="text-lg font-semibold">d4rk_media</h1>
</div>
<div className="flex flex-col gap-3">
<Feld
label="Benutzername"
value={name}
autoComplete="username"
onChange={(e) => setName(e.target.value)}
/>
<Feld
label="Passwort"
type="password"
value={passwort}
autoComplete="current-password"
onChange={(e) => setPasswort(e.target.value)}
/>
</div>
{fehler && (
<p className="mt-3 rounded-md border border-schlecht/40 bg-schlecht/10 px-3 py-2 text-sm text-schlecht">
{fehler}
</p>
)}
<Knopf
art="haupt"
type="submit"
wartet={wartet}
disabled={!name || !passwort}
className="mt-5 w-full"
kind="Anmelden"
/>
</form>
</div>
)
}
+337
View File
@@ -0,0 +1,337 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Check,
ChevronLeft,
ChevronRight,
Copy,
ExternalLink,
FileQuestion,
ImageOff,
Loader2,
Search,
Trash2,
X,
} from 'lucide-react'
import { api, type MediaItem, type MediaPage } from '../api'
import { run, useStore } from '../store'
import { bytes, istBild, zeit } from '../format'
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
const PRO_SEITE = 60
export function Galerie() {
const note = useStore((s) => s.note)
const [suche, setSuche] = useState('')
const [seite, setSeite] = useState(0)
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)
const [loescht, setLoescht] = useState(false)
const { kopiert, kopieren } = useKopieren()
// Die Suche wartet einen Moment ab. Ohne das schickt jeder Tastendruck
// eine Anfrage, und bei "adder" sind das fünf, von denen vier veraltet
// ankommen können.
const [sucheAktiv, setSucheAktiv] = useState('')
useEffect(() => {
const t = setTimeout(() => {
setSucheAktiv(suche)
setSeite(0)
}, 250)
return () => clearTimeout(t)
}, [suche])
// Welche Anfrage die neueste ist. Eine ältere, die später ankommt, darf
// die neuere nicht überschreiben — sonst steht nach schnellem Tippen das
// Ergebnis von vorletzter Eingabe auf dem Schirm.
const lauf = useRef(0)
const laden = useCallback(async () => {
const meine = ++lauf.current
setLaedt(true)
try {
const seiteDaten = await api.media(sucheAktiv, PRO_SEITE, seite * PRO_SEITE)
if (meine !== lauf.current) return
setDaten(seiteDaten)
} catch (err) {
if (meine !== lauf.current) return
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
if (meine === lauf.current) setLaedt(false)
}
}, [sucheAktiv, seite, note])
useEffect(() => {
void laden()
}, [laden])
const items = daten?.items ?? []
const gesamt = daten?.total ?? 0
const seiten = Math.max(1, Math.ceil(gesamt / PRO_SEITE))
function umschalten(id: number) {
setGewaehlt((alt) => {
const neu = new Set(alt)
if (neu.has(id)) neu.delete(id)
else neu.add(id)
return neu
})
}
const alleGewaehlt = items.length > 0 && items.every((i) => gewaehlt.has(i.id))
function alleUmschalten() {
setGewaehlt((alt) => {
const neu = new Set(alt)
if (alleGewaehlt) items.forEach((i) => neu.delete(i.id))
else items.forEach((i) => neu.add(i.id))
return neu
})
}
async function loeschenAusfuehren() {
if (!loeschen) return
setLoescht(true)
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.`,
)
setLoescht(false)
setLoeschen(null)
if (ergebnis) {
setGewaehlt(new Set())
void laden()
}
}
async function adresseKopieren(item: MediaItem) {
const ok = await kopieren(item.url, String(item.id))
if (!ok) note('fehler', 'Die Zwischenablage ist gesperrt — nur über https erreichbar.')
}
return (
<div className="flex flex-col gap-4">
{/* Kopfzeile: Suche, Auswahl, Löschen */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-56 flex-1">
<Search
size={15}
className="absolute top-1/2 left-3 -translate-y-1/2 text-schrift-leise"
/>
<input
value={suche}
onChange={(e) => setSuche(e.target.value)}
placeholder="Pfad durchsuchen, z. B. vehicles/"
className="w-full rounded-md border border-rand bg-flaeche py-1.5 pr-8 pl-9
text-sm placeholder:text-schrift-leise/60 focus:border-akzent
focus:outline-none"
/>
{suche && (
<button
onClick={() => setSuche('')}
className="absolute top-1/2 right-2 -translate-y-1/2 text-schrift-leise hover:text-schrift"
aria-label="Suche leeren"
>
<X size={14} />
</button>
)}
</div>
<span className="text-sm text-schrift-leise">
{laedt ? (
<Loader2 size={15} className="animate-spin" />
) : (
<>
{gesamt} {gesamt === 1 ? 'Datei' : 'Dateien'}
{sucheAktiv && ' gefunden'}
</>
)}
</span>
{items.length > 0 && (
<Knopf
onClick={alleUmschalten}
kind={alleGewaehlt ? 'Auswahl aufheben' : 'Seite wählen'}
/>
)}
{gewaehlt.size > 0 && (
<Knopf
art="gefahr"
onClick={() => setLoeschen(items.filter((i) => gewaehlt.has(i.id)))}
kind={
<>
<Trash2 size={15} />
{gewaehlt.size} löschen
</>
}
/>
)}
</div>
{/* Das Raster */}
{items.length === 0 && !laedt ? (
<Leer
kind={<ImageOff size={28} className="text-schrift-leise/50" />}
text={
sucheAktiv
? `Nichts gefunden für "${sucheAktiv}".`
: 'Noch keine Datei hochgeladen.'
}
/>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
{items.map((item) => {
const an = gewaehlt.has(item.id)
return (
<figure
key={item.id}
className={`group relative overflow-hidden rounded-lg border bg-flaeche
transition-colors ${an ? 'border-akzent' : 'border-rand'}`}
>
<button
onClick={() => umschalten(item.id)}
className="block w-full"
aria-pressed={an}
aria-label={`${item.path} auswählen`}
>
<div className="flex aspect-square items-center justify-center bg-grund">
{istBild(item.mime) ? (
<img
src={item.url}
alt={item.path}
loading="lazy"
className="h-full w-full object-contain"
/>
) : (
<FileQuestion
size={28}
className="text-schrift-leise/50"
/>
)}
</div>
</button>
{an && (
<span className="pointer-events-none absolute top-2 left-2 rounded bg-akzent p-1 text-grund">
<Check size={12} />
</span>
)}
<figcaption className="border-t border-rand p-2">
<p
className="truncate font-mono text-xs text-schrift"
title={item.path}
>
{item.path}
</p>
<p className="mt-0.5 text-[11px] text-schrift-leise">
{bytes(item.size)} · {zeit(item.updated_at)}
</p>
<div className="mt-2 flex gap-1">
<button
onClick={() => void adresseKopieren(item)}
className="flex flex-1 items-center justify-center gap-1 rounded border
border-rand py-1 text-[11px] text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift"
>
{kopiert === String(item.id) ? (
<>
<Check size={11} className="text-gut" />
kopiert
</>
) : (
<>
<Copy size={11} />
Adresse
</>
)}
</button>
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-flaeche-hoch hover:text-schrift"
aria-label="In neuem Tab öffnen"
>
<ExternalLink size={11} />
</a>
<button
onClick={() => setLoeschen([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`}
>
<Trash2 size={11} />
</button>
</div>
</figcaption>
</figure>
)
})}
</div>
)}
{/* Blättern */}
{seiten > 1 && (
<div className="flex items-center justify-center gap-3">
<Knopf
disabled={seite === 0}
onClick={() => setSeite((s) => s - 1)}
kind={<ChevronLeft size={15} />}
/>
<span className="text-sm text-schrift-leise">
Seite {seite + 1} von {seiten}
</span>
<Knopf
disabled={seite + 1 >= seiten}
onClick={() => setSeite((s) => s + 1)}
kind={<ChevronRight size={15} />}
/>
</div>
)}
<Nachfrage
offen={loeschen !== null}
titel={loeschen?.length === 1 ? 'Datei löschen?' : `${loeschen?.length} Dateien löschen?`}
bestaetigung="Endgültig löschen"
wartet={loescht}
onNein={() => setLoeschen(null)}
onJa={() => void loeschenAusfuehren()}
text={
<>
<p className="mb-2">
Die Datei wird von der Platte entfernt. Es gibt keine Sicherung, aus
der sie zurückkommt.
</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 && (
<li className="text-schrift-leise">
und {loeschen.length - 12} weitere
</li>
)}
</ul>
</>
}
/>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { useState, type FormEvent } from 'react'
import { api } from '../api'
import { run, useStore } from '../store'
import { Feld, Karte, Knopf } from '../components/Bausteine'
export function Konto() {
const user = useStore((s) => s.user)
const note = useStore((s) => s.note)
const [aktuell, setAktuell] = useState('')
const [neu, setNeu] = useState('')
const [wieder, setWieder] = useState('')
const [wartet, setWartet] = useState(false)
const zuKurz = neu.length > 0 && neu.length < 8
const ungleich = wieder.length > 0 && neu !== wieder
async function wechseln(e: FormEvent) {
e.preventDefault()
// Die Wiederholung prüft nur diese Seite — der Dienst kennt sie nicht.
// Deshalb hier ein eigener Satz statt einer Anfrage, die nichts
// bewirken kann.
if (neu !== wieder) {
note('fehler', 'Die beiden neuen Passwörter stimmen nicht überein.')
return
}
setWartet(true)
const ok = await run(
() => api.changePassword(aktuell, neu),
'Passwort geändert. Alle anderen Anmeldungen wurden beendet.',
)
setWartet(false)
if (ok) {
setAktuell('')
setNeu('')
setWieder('')
}
}
return (
<div className="max-w-md">
<Karte titel={`Angemeldet als ${user?.username ?? '—'}`}>
<form onSubmit={wechseln} className="flex flex-col gap-3">
<Feld
label="Aktuelles Passwort"
type="password"
autoComplete="current-password"
value={aktuell}
onChange={(e) => setAktuell(e.target.value)}
/>
<Feld
label="Neues Passwort"
type="password"
autoComplete="new-password"
value={neu}
onChange={(e) => setNeu(e.target.value)}
hinweis={zuKurz ? 'Mindestens 8 Zeichen.' : undefined}
/>
<Feld
label="Neues Passwort wiederholen"
type="password"
autoComplete="new-password"
value={wieder}
onChange={(e) => setWieder(e.target.value)}
hinweis={ungleich ? 'Stimmt nicht mit dem neuen Passwort überein.' : undefined}
/>
<p className="text-xs text-schrift-leise">
Ein Wechsel beendet alle anderen Sitzungen. Diese hier bleibt bestehen.
</p>
<div>
<Knopf
art="haupt"
type="submit"
wartet={wartet}
disabled={!aktuell || neu.length < 8 || neu !== wieder}
kind="Passwort ändern"
/>
</div>
</form>
</Karte>
</div>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react'
import { Check, Copy, Folder, HardDrive, KeyRound, Loader2 } from 'lucide-react'
import { api, type Stats } from '../api'
import { useStore } from '../store'
import { bytes } from '../format'
import { Karte, Leer, useKopieren } from '../components/Bausteine'
export function Speicher() {
const note = useStore((s) => s.note)
const [stats, setStats] = useState<Stats | null>(null)
const [laedt, setLaedt] = useState(true)
const { kopiert, kopieren } = useKopieren()
useEffect(() => {
let lebt = true
api.stats()
.then((s) => lebt && setStats(s))
.catch(
(err) =>
lebt && note('fehler', err instanceof Error ? err.message : 'Unbekannt'),
)
.finally(() => lebt && setLaedt(false))
return () => {
lebt = false
}
}, [note])
if (laedt) {
return (
<div className="flex justify-center py-12">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
)
}
if (!stats) return <Leer text="Die Statistik ließ sich nicht laden." />
const groesster = Math.max(1, ...stats.folders.map((f) => f.bytes))
return (
<div className="flex flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-3">
<Zahl kind={<HardDrive size={16} />} was="Dateien" wert={String(stats.files)} />
<Zahl kind={<Folder size={16} />} was="Belegt" wert={bytes(stats.bytes)} />
<Zahl kind={<KeyRound size={16} />} was="Token" wert={String(stats.tokens)} />
</div>
<Karte titel="Öffentliche Adresse">
<div className="flex items-center gap-2">
<code className="flex-1 truncate rounded border border-rand bg-grund px-3 py-1.5 font-mono text-sm">
{stats.publicUrl}
</code>
<button
onClick={() => void kopieren(stats.publicUrl, 'url')}
className="rounded border border-rand px-2.5 py-1.5 text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
aria-label="Adresse kopieren"
>
{kopiert === 'url' ? (
<Check size={14} className="text-gut" />
) : (
<Copy size={14} />
)}
</button>
</div>
<p className="mt-2 text-xs text-schrift-leise">
Dateien liegen darunter unter <code className="font-mono">/f/</code>. Diese
Adresse steht nirgends in der Datenbank sie wird bei jeder Antwort aus{' '}
<code className="font-mono">PUBLIC_URL</code> gebaut, ein Umzug ist deshalb
eine geänderte Variable.
</p>
</Karte>
<Karte titel="Größte Ordner">
{stats.folders.length === 0 ? (
<Leer text="Noch nichts abgelegt." />
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-schrift-leise">
<th className="pb-2 font-medium">Ordner</th>
<th className="pb-2 text-right font-medium">Dateien</th>
<th className="pb-2 text-right font-medium">Belegt</th>
<th className="w-1/3 pb-2 pl-4 font-medium">Anteil</th>
</tr>
</thead>
<tbody>
{stats.folders.map((f) => (
<tr key={f.folder} className="border-t border-rand">
<td className="py-2 font-mono">{f.folder}</td>
<td className="py-2 text-right text-schrift-leise">
{f.files}
</td>
<td className="py-2 text-right">{bytes(f.bytes)}</td>
<td className="py-2 pl-4">
<div className="h-1.5 w-full rounded-full bg-grund">
<div
className="h-1.5 rounded-full bg-akzent"
style={{
width: `${(f.bytes / groesster) * 100}%`,
}}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Karte>
</div>
)
}
function Zahl({ kind, was, wert }: { kind: React.ReactNode; was: string; wert: string }) {
return (
<div className="rounded-lg border border-rand bg-flaeche p-4">
<div className="flex items-center gap-2 text-xs text-schrift-leise">
{kind}
{was}
</div>
<p className="mt-1 text-2xl font-semibold">{wert}</p>
</div>
)
}
+269
View File
@@ -0,0 +1,269 @@
import { useEffect, useState, type FormEvent } from 'react'
import { AlertTriangle, Check, Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'
import { api, type NewToken, type TokenRow } from '../api'
import { run, useStore } from '../store'
import { zeit } from '../format'
import { Feld, Karte, Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
export function Tokens() {
const note = useStore((s) => s.note)
const [rows, setRows] = useState<TokenRow[] | null>(null)
const [laedt, setLaedt] = useState(true)
const [name, setName] = useState('')
const [prefix, setPrefix] = useState('')
const [darfLoeschen, setDarfLoeschen] = useState(false)
const [legtAn, setLegtAn] = useState(false)
const [frisch, setFrisch] = useState<NewToken | null>(null)
const [weg, setWeg] = useState<TokenRow | null>(null)
const [loescht, setLoescht] = useState(false)
const { kopiert, kopieren } = useKopieren()
async function laden() {
setLaedt(true)
try {
const { items } = await api.tokens()
setRows(items)
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
} finally {
setLaedt(false)
}
}
useEffect(() => {
void laden()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function anlegen(e: FormEvent) {
e.preventDefault()
setLegtAn(true)
const neu = await run(
() => api.createToken(name.trim(), prefix.trim(), darfLoeschen),
(t) => `Token "${t.name}" angelegt.`,
)
setLegtAn(false)
if (neu) {
setFrisch(neu)
setName('')
setPrefix('')
setDarfLoeschen(false)
void laden()
}
}
async function loeschen() {
if (!weg) return
setLoescht(true)
const ok = await run(() => api.deleteToken(weg.id), `Token "${weg.name}" gelöscht.`)
setLoescht(false)
setWeg(null)
if (ok) void laden()
}
return (
<div className="flex flex-col gap-4">
<Karte titel="Neuen Token anlegen">
<form onSubmit={anlegen} className="flex flex-col gap-3">
<div className="grid gap-3 sm:grid-cols-2">
<Feld
label="Name"
value={name}
placeholder="d4rk_photostudio"
hinweis="Nur zur Wiedererkennung in dieser Liste."
onChange={(e) => setName(e.target.value)}
/>
<Feld
label="Präfix (optional)"
value={prefix}
placeholder="vehicles"
hinweis="Der Token darf dann nur darunter schreiben."
onChange={(e) => setPrefix(e.target.value)}
/>
</div>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={darfLoeschen}
onChange={(e) => setDarfLoeschen(e.target.checked)}
className="mt-0.5 accent-akzent"
/>
<span>
darf löschen
<span className="block text-xs text-schrift-leise">
Ohne dieses Häkchen kann der Token nur schreiben. Ein Werkzeug,
das nur Bilder ablegt, braucht es nicht.
</span>
</span>
</label>
<div>
<Knopf
art="haupt"
type="submit"
wartet={legtAn}
disabled={!name.trim()}
kind={
<>
<Plus size={15} />
Anlegen
</>
}
/>
</div>
</form>
</Karte>
<Karte titel="Vorhandene Token">
{laedt ? (
<div className="flex justify-center py-8">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
) : !rows || rows.length === 0 ? (
<Leer
kind={<KeyRound size={26} className="text-schrift-leise/50" />}
text="Noch kein Token angelegt."
/>
) : (
<table className="w-full text-sm">
<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">Rechte</th>
<th className="pb-2 font-medium">Angelegt</th>
<th className="pb-2 font-medium">Zuletzt benutzt</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{rows.map((t) => (
<tr key={t.id} className="border-t border-rand">
<td className="py-2 font-medium">{t.name}</td>
<td className="py-2 font-mono text-schrift-leise">
{t.prefix ? `${t.prefix}/` : '— (überall)'}
</td>
<td className="py-2">
{t.can_delete ? (
<span className="rounded bg-warn/15 px-1.5 py-0.5 text-xs text-warn">
schreiben + löschen
</span>
) : (
<span className="text-xs text-schrift-leise">
nur schreiben
</span>
)}
</td>
<td className="py-2 text-schrift-leise">
{zeit(t.created_at)}
</td>
<td className="py-2 text-schrift-leise">
{zeit(t.last_used_at)}
</td>
<td className="py-2 text-right">
<button
onClick={() => setWeg(t)}
className="rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-schlecht/15 hover:text-schlecht"
aria-label={`Token ${t.name} löschen`}
>
<Trash2 size={13} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</Karte>
{/* Der Klartext — genau einmal.
Deshalb ein Dialog, den man bewusst schließen muss, und kein
Zettel, der nach vier Sekunden verschwindet. */}
{frisch && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-lg rounded-lg border border-rand bg-flaeche shadow-xl">
<h3 className="flex items-center gap-2 border-b border-rand px-4 py-3 text-sm font-semibold">
<KeyRound size={16} className="text-akzent" />
Token {frisch.name}" angelegt
</h3>
<div className="px-4 py-4">
<p className="mb-3 flex items-start gap-2 rounded-md border border-warn/40 bg-warn/10 px-3 py-2 text-sm text-warn">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<span className="text-schrift">
Dies ist das einzige Mal, dass der Token im Klartext zu sehen
ist. Danach steht nur noch sein Hash in der Datenbank — auch
der Dienst kann ihn nicht mehr zeigen.
</span>
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded border border-rand bg-grund px-3 py-2 font-mono text-sm">
{frisch.token}
</code>
<Knopf
art="haupt"
onClick={async () => {
const ok = await kopieren(frisch.token, 'neu')
if (!ok)
note(
'fehler',
'Die Zwischenablage ist gesperrt — bitte von Hand markieren.',
)
}}
kind={
kopiert === 'neu' ? (
<>
<Check size={15} />
kopiert
</>
) : (
<>
<Copy size={15} />
Kopieren
</>
)
}
/>
</div>
<p className="mt-3 text-xs text-schrift-leise">
Präfix: <code className="font-mono">{frisch.prefix || '—'}</code>{' '}
· Löschen: {frisch.canDelete ? 'erlaubt' : 'nicht erlaubt'}
</p>
</div>
<div className="flex justify-end border-t border-rand px-4 py-3">
<Knopf
onClick={() => setFrisch(null)}
kind="Habe ich mir notiert"
/>
</div>
</div>
</div>
)}
<Nachfrage
offen={weg !== null}
titel="Token löschen?"
bestaetigung="Token löschen"
wartet={loescht}
onNein={() => setWeg(null)}
onJa={() => void loeschen()}
text={
<>
<p>
„{weg?.name}" wird sofort ungültig. Was ihn benutzt etwa der
Fotostudio bekommt ab dann 401 und lädt nichts mehr hoch.
</p>
<p className="mt-2">Hochgeladene Dateien bleiben.</p>
</>
}
/>
</div>
)
}