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