Files
EventSnap/frontend/src/routes/admin/+page.svelte
Fabian Hamm (Privat) 2b1500e624 fix(ui): make the dashboards agree with each other and with what the code does
Host and admin implement the same four operations with independently written copy, and
admin was stale or wrong in every case. Its release button was always enabled and always
read "Galerie freigeben", so a second tap returned a 409; it showed no release state, no
keepsake progress, no failure reason, no rebuild, and never refreshed after releasing.
It now matches the host page.

Both dashboards subscribed to SSE and never opened the connection — `onSseEvent` only
registers a handler. Every subscription was inert, so the keepsake progress bar sat
frozen after a release and PIN requests appeared only on a manual refresh. It happened
to work when arriving straight from /feed, which connects, and /feed disconnects on
destroy, so navigating to the dashboard killed it again.

The unban confirm named neither of the two things a host most needs to know: unbanning
also restores ALL of that guest's previously hidden photos to the gallery, diashow and
export, and it retires and rebuilds a released keepsake, during which every guest's
download is briefly unavailable. The ban modal warns that uploads vanish; nothing said
they come back. Both now do, gated on the gallery actually being released.

"Event verlassen" implied the account was being deleted, then the dialog said the guest
could log back in. It calls `DELETE /session` — this device only, nothing deleted — so
it is "Abmelden" now. Gallery release now states it locks uploads and is reversible; PIN
reset states the guest is signed out on all devices.

The keepsake download failed silently: nothing inspected the iframe result and the
ticket POST always succeeded, so an over-limit tap did nothing at all. It now surfaces
the (newly visible) 429 and confirms the download started. `/export` rendered "Export
noch nicht verfügbar / Schau nach der Veranstaltung noch einmal vorbei" when the status
request had merely FAILED — telling a guest to come back after an event that already
happened. Both dashboards' error states gained a retry, which a host on a PWA with no
URL bar otherwise has no way to reach.

Modals were centred with no max-height, so on a short viewport the join PIN dialog
clipped equally top and bottom — potentially putting "Weiter zur Galerie" off-screen at
the moment a first-time guest must proceed. The ten moderation buttons were ~28px tall
side by side, on the screen where a mis-tap bans the wrong guest; they are 44px now.
Six German quotation marks paired the opening „ with an ASCII straight quote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:36:57 +02:00

1401 lines
51 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken } from '$lib/auth';
import { role as myRoleStore } from '$lib/role-store';
import { api } from '$lib/api';
import { onMount } from 'svelte';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import { toast, toastError } from '$lib/toast-store';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import Modal from '$lib/components/Modal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { PRESETS, DEFAULT_SEED, buildPaletteCss, type ThemeConfig } from '$lib/theme/palette';
import {
previewTheme,
PALETTE_CACHE_KEY,
loadEventConfig,
commentsEnabled
} from '$lib/event-config-store';
import { onDestroy } from 'svelte';
interface StatsDto {
user_count: number;
upload_count: number;
comment_count: number;
disk_total_bytes: number;
disk_used_bytes: number;
disk_free_bytes: number;
}
interface ExportJob {
id: string;
type: string;
status: string;
progress_pct: number;
error_message: string | null;
created_at: string;
completed_at: string | null;
}
// `/export/status` — the same derived view the host dashboard and the guest export page
// use (released + per-half job state). Deliberately separate from `ExportJob` above,
// which is the admin-only raw job-row listing.
interface ExportStatusJob {
status: string;
progress_pct: number;
error_message: string | null;
}
interface ExportStatusDto {
released: boolean;
zip: ExportStatusJob | null;
html: ExportStatusJob | null;
}
interface UserSummary {
id: string;
display_name: string;
role: string;
is_banned: boolean;
uploads_hidden: boolean;
upload_count: number;
total_upload_bytes: number;
created_at: string;
}
type ConfigKind = 'number' | 'bool' | 'text';
interface ConfigField {
key: string;
label: string;
kind: ConfigKind;
hint?: string;
}
interface ConfigGroup {
title: string;
fields: ConfigField[];
}
// Grouped sections — adding a new key is one entry in the right group, no other
// code changes required. The form renders each field based on `kind`.
const CONFIG_GROUPS: ConfigGroup[] = [
{
title: 'Limits & Größen',
fields: [
{ key: 'max_image_size_mb', label: 'Max. Bildgröße (MB)', kind: 'number' },
{ key: 'max_video_size_mb', label: 'Max. Videogröße (MB)', kind: 'number' }
// compression_concurrency is set via COMPRESSION_WORKER_CONCURRENCY at
// boot, not live — omitted so it isn't a dead no-op control.
]
},
{
title: 'Rate-Limits',
fields: [
{
key: 'rate_limits_enabled',
label: 'Rate-Limits aktiv',
kind: 'bool',
hint: 'Hauptschalter — wenn aus, sind alle Rate-Limits deaktiviert.'
},
{ key: 'upload_rate_enabled', label: 'Upload-Limit aktiv', kind: 'bool' },
{ key: 'feed_rate_enabled', label: 'Feed-Limit aktiv', kind: 'bool' },
{ key: 'export_rate_enabled', label: 'Export-Limit aktiv', kind: 'bool' },
{ key: 'join_rate_enabled', label: 'Join-Limit aktiv', kind: 'bool' },
{ key: 'social_rate_enabled', label: 'Interaktions-Limit aktiv', kind: 'bool' },
{ key: 'upload_rate_per_hour', label: 'Upload-Limit pro Stunde', kind: 'number' },
{ key: 'feed_rate_per_min', label: 'Feed-Anfragen pro Minute', kind: 'number' },
{ key: 'export_rate_per_day', label: 'Export-Downloads pro Tag', kind: 'number' },
{
key: 'social_rate_per_min',
label: 'Interaktionen pro Minute',
kind: 'number',
hint: 'Likes, Kommentare und Kommentar-Löschungen zusammen, pro Gast. Bewusst hoch angesetzt — soll ein Skript bremsen, keinen begeisterten Gast.'
}
]
},
{
title: 'Quoten',
fields: [
{
key: 'quota_enabled',
label: 'Quoten aktiv',
kind: 'bool',
hint: 'Hauptschalter — wenn aus, wird nichts geprüft.'
},
{ key: 'storage_quota_enabled', label: 'Speicher-Quote aktiv', kind: 'bool' },
{
key: 'upload_count_quota_enabled',
label: 'Upload-Anzahl-Quote aktiv',
kind: 'bool',
hint: 'Reserviert für künftige Anzahl-Limits.'
},
{
key: 'quota_tolerance',
label: 'Speicher-Anteil für Gäste (01)',
kind: 'number',
// "Toleranz (01)" with no hint invited exactly the wrong reading — that a higher
// number means "warn me later". It is the multiplier in
// `floor(freier Speicher × Anteil / aktive Uploader)`, so raising it authorises
// guests to fill MORE of the disk, not less.
hint:
'Anteil des freien Speichers, den alle Gäste zusammen belegen dürfen: ' +
'Limit = freier Speicher × Anteil ÷ aktive Uploader. Kein Warnschwellenwert — ' +
'ein höherer Wert gibt MEHR Speicher frei. Das Keepsake braucht zusätzlich ' +
'etwa das Doppelte der Mediengröße; 0,75 ist der getestete Standard.'
},
{ key: 'estimated_guest_count', label: 'Geschätzte Gästezahl', kind: 'number' }
]
},
{
title: 'Datenschutzhinweis',
fields: [
{
key: 'privacy_note',
label: 'Datenschutzhinweis (freier Text)',
kind: 'text',
hint: 'Wird wörtlich im Konto-Bereich angezeigt. Kein HTML — Leerzeichen und Zeilenumbrüche werden übernommen.'
}
]
}
];
function isTrue(v: string | undefined): boolean {
if (!v) return false;
return ['true', '1', 'yes', 'on'].includes(v.trim().toLowerCase());
}
function toggleBool(key: string) {
configDraft = { ...configDraft, [key]: isTrue(configDraft[key]) ? 'false' : 'true' };
}
// ── Colour theme ─────────────────────────────────────────────────────────
// The theme lives in the same `config` table but needs a bespoke UI (swatches +
// pickers + live preview) rather than the generic field renderer. Selecting a
// preset or nudging a colour recolours the whole admin page instantly (preview);
// Save persists it and refreshes every open client via the event-updated SSE.
let themePreset = $state('champagne-gold');
let themePrimary = $state(DEFAULT_SEED);
let themeAccent = $state(DEFAULT_SEED);
let themeSaving = $state(false);
// The persisted theme, so leaving without saving reverts the live preview.
let savedTheme: ThemeConfig = {
preset: 'champagne-gold',
primary: DEFAULT_SEED,
accent: DEFAULT_SEED
};
function currentTheme(): ThemeConfig {
return { preset: themePreset, primary: themePrimary, accent: themeAccent };
}
async function loadTheme() {
try {
const b = await api.get<{
theme_preset: string;
theme_primary: string;
theme_accent: string;
}>('/event');
themePreset = b.theme_preset ?? 'champagne-gold';
themePrimary = b.theme_primary ?? DEFAULT_SEED;
themeAccent = b.theme_accent ?? DEFAULT_SEED;
savedTheme = currentTheme();
} catch {
/* keep defaults — the theme card just shows champagne-gold */
}
}
function selectPreset(id: string) {
themePreset = id;
if (id !== 'custom') {
const p = PRESETS.find((x) => x.id === id);
if (p) {
themePrimary = p.primary;
themeAccent = p.accent;
}
}
previewTheme(currentTheme());
}
// A colour picker moved → force preset to 'custom' and preview live.
function onCustomColor() {
themePreset = 'custom';
previewTheme(currentTheme());
}
async function saveTheme() {
themeSaving = true;
try {
const theme = currentTheme();
await api.patch('/admin/config', {
theme_preset: theme.preset,
theme_primary: theme.primary,
theme_accent: theme.accent
});
savedTheme = theme;
try {
localStorage.setItem(PALETTE_CACHE_KEY, buildPaletteCss(theme));
} catch {
/* non-fatal */
}
toast('Farbschema gespeichert.', 'success');
} catch (e: unknown) {
toastError(e);
} finally {
themeSaving = false;
}
}
function resetTheme() {
selectPreset('champagne-gold');
}
const themeDirty = $derived(
themePreset !== savedTheme.preset ||
themePrimary.toLowerCase() !== savedTheme.primary.toLowerCase() ||
themeAccent.toLowerCase() !== savedTheme.accent.toLowerCase()
);
// SSE unsubscribers for the live keepsake status, torn down on destroy.
let sseOff: Array<() => void> = [];
let destroyed = false;
// Leaving the admin page without saving must not strand an unsaved preview on the
// rest of the app — revert to the persisted theme. loadEventConfig re-applies the
// authoritative palette + cache.
onDestroy(() => {
destroyed = true;
disconnectSse();
for (const off of sseOff) off();
void loadEventConfig();
});
type AdminTab = 'stats' | 'config' | 'export' | 'users';
const TAB_LABELS: Record<AdminTab, string> = {
stats: 'Stats',
config: 'Config',
export: 'Export',
users: 'Nutzer'
};
let activeTab = $state<AdminTab>('stats');
let stats = $state<StatsDto | null>(null);
let config = $state<Record<string, string>>({});
let configDraft = $state<Record<string, string>>({});
let exportJobs = $state<ExportJob[]>([]);
let users = $state<UserSummary[]>([]);
let loading = $state(true);
let saving = $state(false);
let error = $state<string | null>(null);
let exportJobsRefreshing = $state(false);
// ── Keepsake state (parity with the host dashboard) ─────────────────────────
// The Export tab used to offer a release button that was always enabled and always
// labelled "Galerie freigeben", with no indication of whether the release had already
// happened, how the build was going, or why it failed — so a second tap just returned
// 409 "bereits freigegeben", and a failed keepsake was invisible AND unrecoverable from
// here. Same data, same controls and the same wording as /host now.
let exportInfo = $state<ExportStatusDto | null>(null);
let rebuilding = $state(false);
let exportReady = $derived(
exportInfo?.zip?.status === 'done' && exportInfo?.html?.status === 'done'
);
// `&&`, not `||`: the keepsake needs BOTH halves, so one failing half means the whole
// thing failed. With `||` a one-half failure would sit on "wird erstellt…" forever and
// never reveal the retry.
let exportGenerating = $derived(
!!exportInfo?.released &&
!exportReady &&
exportInfo?.zip?.status !== 'failed' &&
exportInfo?.html?.status !== 'failed'
);
let exportProgress = $derived(
Math.min(exportInfo?.zip?.progress_pct ?? 0, exportInfo?.html?.progress_pct ?? 0)
);
// A disk failure usually fails both halves with the same text — take the first present
// rather than printing it twice.
let exportError = $derived(
exportInfo?.zip?.error_message ?? exportInfo?.html?.error_message ?? null
);
// Nutzer tab state
let userSearch = $state('');
let filteredUsers = $derived(
userSearch.trim()
? users.filter((u) => u.display_name.toLowerCase().includes(userSearch.toLowerCase()))
: users
);
// Ban modal state
let banTarget = $state<UserSummary | null>(null);
let banSubmitting = $state(false);
// PIN reset state — `pinModal` holds the freshly-issued plaintext PIN. We forget it
// the moment the modal closes.
let pinResetTarget = $state<UserSummary | null>(null);
let pinResetSubmitting = $state(false);
let pinModal = $state<{ name: string; pin: string } | null>(null);
// Live role, not the frozen JWT claim: a demotion must disable these controls at once.
const myRole = $derived($myRoleStore);
// Generic confirm-then-run for irreversible / privilege-changing actions
// (promote, demote, unban, release gallery). Reuses the shared ConfirmSheet.
interface PendingConfirm {
title: string;
message: string;
confirmLabel: string;
tone: 'default' | 'danger';
run: () => Promise<void>;
}
let confirmAction = $state<PendingConfirm | null>(null);
async function runConfirmAction() {
const action = confirmAction;
if (!action) return;
await action.run();
confirmAction = null;
}
onMount(async () => {
const token = getToken();
if (!token) {
goto('/admin/login');
return;
}
// Trust the *live* role, not the JWT claim: a mid-session demote leaves a
// stale 'admin' in the token, but the backend now 403s every admin call.
// Bounce a demoted admin to the feed instead of leaving them on a dashboard
// that errors on load (mirrors the host page).
try {
const ctx = await api.get<{ role: string }>('/me/context');
if (ctx.role !== 'admin') {
goto('/feed');
return;
}
} catch {
// Expired/invalid session (api.ts cleared it) — send them to re-auth.
goto('/admin/login');
return;
}
await reload();
// The awaits above mean the component can already be destroyed by the time we get here
// (navigated away mid-load). Svelte doesn't cancel an async onMount, so without this
// guard onDestroy would have run with an empty `sseOff` and these handlers would leak
// into the module-global SSE map forever. (Same guard as the host page.)
if (destroyed) return;
// Open the stream — `onSseEvent` only registers a handler, it does not connect. Without
// this the subscriptions below never fire and the keepsake status is frozen; see the
// same fix and reasoning on the host dashboard. Idempotent.
connectSse();
// Keepsake generation moves → refresh the status line and the job list without a
// manual tap, exactly as the host dashboard does.
sseOff = [
onSseEvent('export-progress', () => void refreshExportStatus()),
onSseEvent('export-available', () => {
void refreshExportStatus();
void refreshExportJobs();
})
];
});
async function reload() {
loading = true;
error = null;
try {
[stats, config, exportJobs, users] = await Promise.all([
api.get<StatsDto>('/admin/stats'),
api.get<Record<string, string>>('/admin/config'),
api.get<ExportJob[]>('/admin/export/jobs'),
api.get<UserSummary[]>('/host/users')
]);
configDraft = { ...config };
void loadTheme();
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
} finally {
loading = false;
}
// Keepsake status is a secondary widget — fetched OUTSIDE the all-or-nothing block
// above (and its error swallowing) so a transient /export/status failure can't blank
// the whole dashboard. Mirrors the host page.
void refreshExportStatus();
}
/** Refetch just the keepsake status (released / live progress / failure reason). */
async function refreshExportStatus() {
try {
exportInfo = await api.get<ExportStatusDto>('/export/status');
} catch {
/* non-fatal — the next refresh or SSE tick picks it up */
}
}
async function refreshExportJobs() {
exportJobsRefreshing = true;
try {
// The raw job rows and the derived status are two views of the same thing; a manual
// refresh that updated only one of them would leave the two halves of this tab
// disagreeing about whether the keepsake is ready.
await Promise.all([
(async () => {
exportJobs = await api.get<ExportJob[]>('/admin/export/jobs');
})(),
refreshExportStatus()
]);
} finally {
exportJobsRefreshing = false;
}
}
// Keys rendered as number inputs — used to reject empty/invalid numeric saves.
const NUMBER_KEYS = new Set(
CONFIG_GROUPS.flatMap((g) => g.fields.filter((f) => f.kind === 'number').map((f) => f.key))
);
async function saveConfig() {
// Don't let a cleared number field persist as an empty/NaN config value.
for (const key of NUMBER_KEYS) {
const v = configDraft[key];
if (
v !== undefined &&
v !== config[key] &&
(String(v).trim() === '' || !Number.isFinite(Number(v)))
) {
toastError(new Error('Bitte gib für alle Zahlenfelder einen gültigen Wert ein.'));
return;
}
}
saving = true;
try {
const changes: Record<string, string> = {};
for (const key of Object.keys(configDraft)) {
if (configDraft[key] !== config[key]) {
changes[key] = String(configDraft[key]);
}
}
if (Object.keys(changes).length === 0) {
toast('Keine Änderungen.', 'info');
return;
}
await api.patch('/admin/config', changes);
config = { ...configDraft };
toast('Konfiguration gespeichert.', 'success');
} catch (e: unknown) {
toastError(e);
} finally {
saving = false;
}
}
async function releaseGallery() {
try {
await api.post('/host/gallery/release');
toast('Galerie wurde freigegeben. Export wird vorbereitet…', 'success');
// Without this the Export tab kept showing the pre-release world: no jobs in the
// list, the button still inviting a second (409-ing) release.
await reload();
} catch (e: unknown) {
toastError(e);
// Release stamps `export_released_at` before enqueuing the workers; if that second
// step errored the event IS released, so reconcile rather than leaving a button that
// reads "Galerie freigeben" and can now only 409.
await reload();
}
}
// The escape hatch for a failed or stale keepsake — the host dashboard has had it; without
// it here the only recovery from the admin side was reopening uploads (which retracts the
// release for every guest) or restarting the container.
async function rebuildExport() {
rebuilding = true;
try {
await api.post('/host/export/rebuild', {});
toast('Keepsake wird neu erstellt…', 'success');
await refreshExportJobs();
} catch (e: unknown) {
toastError(e);
} finally {
rebuilding = false;
}
}
function openBanModal(user: UserSummary) {
banTarget = user;
}
async function confirmBan() {
if (!banTarget) return;
banSubmitting = true;
try {
await api.post(`/host/users/${banTarget.id}/ban`, {});
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
banTarget = null;
users = await api.get<UserSummary[]>('/host/users');
// A ban on a released event retires and rebuilds the keepsake (`invalidate_and_arm`),
// so the status block above is stale the moment this returns.
void refreshExportStatus();
} catch (e: unknown) {
toastError(e);
} finally {
banSubmitting = false;
}
}
async function unban(user: UserSummary) {
try {
await api.post(`/host/users/${user.id}/unban`);
toast(`Sperre für ${user.display_name} aufgehoben.`, 'success');
users = await api.get<UserSummary[]>('/host/users');
// Same as ban: an unban rebuilds a released keepsake, so re-read the status.
void refreshExportStatus();
} catch (e: unknown) {
toastError(e);
}
}
/**
* Copy for the unban confirm — same text as the host dashboard, same reasons. Verified
* against `unban_user` (backend/src/handlers/host.rs): besides lifting the write block it
* clears `uploads_hidden` (so every photo the ban hid returns to gallery, diashow and
* export) and calls `invalidate_and_arm` (so a released keepsake is retired and rebuilt,
* and guest downloads are unavailable meanwhile). The ban modal warns that uploads vanish;
* this is the sentence that says they come back.
*/
function unbanMessage(user: UserSummary): string {
const actions = $commentsEnabled ? ', liken und kommentieren' : ' und liken';
let msg =
`${user.display_name} kann danach wieder hochladen${actions}. ` +
`Alle bisher ausgeblendeten Uploads von ${user.display_name} erscheinen wieder in Galerie, Diashow und Export.`;
if (exportInfo?.released) {
msg +=
' Das bereits freigegebene Keepsake wird deshalb neu erstellt — währenddessen können Gäste es kurz nicht herunterladen.';
}
return msg;
}
async function promoteToHost(user: UserSummary) {
try {
await api.patch(`/host/users/${user.id}/role`, { role: 'host' });
toast(`${user.display_name} ist jetzt Host.`, 'success');
users = await api.get<UserSummary[]>('/host/users');
} catch (e: unknown) {
toastError(e);
}
}
async function demoteToGuest(user: UserSummary) {
try {
await api.patch(`/host/users/${user.id}/role`, { role: 'guest' });
toast(`${user.display_name} ist jetzt Gast.`, 'success');
users = await api.get<UserSummary[]>('/host/users');
} catch (e: unknown) {
toastError(e);
}
}
function askResetPin(user: UserSummary) {
pinResetTarget = user;
}
async function confirmResetPin() {
if (!pinResetTarget) return;
pinResetSubmitting = true;
try {
const res = await api.post<{ pin: string }>(`/host/users/${pinResetTarget.id}/pin-reset`);
pinModal = { name: pinResetTarget.display_name, pin: res.pin };
pinResetTarget = null;
} catch (e: unknown) {
toastError(e);
} finally {
pinResetSubmitting = false;
}
}
function copyPinModal() {
if (!pinModal) return;
navigator.clipboard.writeText(pinModal.pin);
toast('PIN kopiert.', 'success');
}
/** True iff the current caller may reset this target's PIN. Mirrors the backend
* rules in `handlers::host::reset_user_pin`. */
function canResetPinFor(target: UserSummary): boolean {
if (target.role === 'admin') return false;
if (myRole === 'admin') return true; // any non-admin
if (myRole === 'host') return target.role === 'guest';
return false;
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
function diskPct(s: StatsDto): number {
if (s.disk_total_bytes === 0) return 0;
return Math.round((s.disk_used_bytes / s.disk_total_bytes) * 100);
}
function jobLabel(type: string): string {
return type === 'zip' ? 'ZIP-Archiv' : 'HTML-Viewer';
}
function statusBadgeClass(status: string): string {
switch (status) {
case 'done':
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200';
case 'running':
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200';
case 'failed':
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200';
default:
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
}
}
function statusLabel(status: string): string {
switch (status) {
case 'pending':
return 'Ausstehend';
case 'running':
return 'Läuft';
case 'done':
return 'Fertig';
case 'failed':
return 'Fehlgeschlagen';
default:
return status;
}
}
</script>
<!-- Confirmation for irreversible / privilege-changing actions (promote/demote/unban/release). -->
<ConfirmSheet
open={confirmAction !== null}
title={confirmAction?.title ?? ''}
message={confirmAction?.message ?? ''}
confirmLabel={confirmAction?.confirmLabel ?? 'Bestätigen'}
tone={confirmAction?.tone ?? 'default'}
onConfirm={runConfirmAction}
onCancel={() => (confirmAction = null)}
/>
<!-- PIN reset confirmation — pure yes/no, uses the shared ConfirmSheet. -->
<ConfirmSheet
open={pinResetTarget !== null}
title="PIN zurücksetzen"
message={pinResetTarget
? // Same fix as the host page: the reset revokes every session, so say so.
`Eine neue PIN für ${pinResetTarget.display_name} wird erzeugt. Die alte PIN funktioniert dann nicht mehr, und ${pinResetTarget.display_name} wird auf allen Geräten abgemeldet und muss sich mit der neuen PIN neu anmelden.`
: ''}
confirmLabel={pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
tone="danger"
onConfirm={confirmResetPin}
onCancel={() => (pinResetTarget = null)}
/>
<!-- One-time PIN display modal — focus-trapped, aria-modal, Escape-dismissable. -->
<Modal open={pinModal !== null} titleId="admin-pin-modal-title" onClose={() => (pinModal = null)}>
{#if pinModal}
<h2 id="admin-pin-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">
Neue PIN für {pinModal.name}
</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Zeige diese PIN dem Benutzer. Sie wird nur einmal angezeigt — beim Schließen wird sie
verworfen.
</p>
<div
class="mb-4 flex items-center justify-between rounded-lg bg-amber-50 px-4 py-3 dark:bg-amber-950/30"
>
<span class="font-mono text-3xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
>{pinModal.pin}</span
>
<button
onclick={copyPinModal}
class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-200 active:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60 dark:active:bg-amber-900/60"
>
Kopieren
</button>
</div>
<button onclick={() => (pinModal = null)} class="btn btn-primary btn-block btn-sm">
Schließen
</button>
{/if}
</Modal>
<!-- Ban modal — ban always hides now, so this is a plain confirm (no checkbox). -->
<Modal open={banTarget !== null} titleId="admin-ban-modal-title" onClose={() => (banTarget = null)}>
{#if banTarget}
<h2 id="admin-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">
Benutzer sperren
</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
<!-- Was: "…und die Sitzung wird beendet." That is the opposite of what happens — the
ban is deliberately READ-ONLY (backend/src/handlers/host.rs: sessions are not
revoked, and the export ticket is intentionally not gated on is_banned). An
admin told the user is logged out stops watching, while the guest keeps
browsing and still downloads the keepsake. Text below matches the host page,
which had it right. -->
<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus
Galerie, Diashow und Export, und Hochladen, Liken{$commentsEnabled ? ' und Kommentieren' : ''} werden
blockiert. Der Lesezugriff (Feed ansehen, Keepsake herunterladen) bleibt bestehen. Rückgängig machbar
über „Entsperren“.
{#if exportInfo?.released}
<!-- Mirror of the unban sentence: `ban_user` runs `invalidate_and_arm` in the same
transaction, so on a released event the keepsake is retired and rebuilt and every
guest's download 404s until that finishes. Only shown when there is a released
keepsake to lose. -->
Da die Galerie bereits freigegeben ist, wird das Keepsake ohne diese Uploads neu erstellt — währenddessen
können Gäste es kurz nicht herunterladen.
{/if}
</p>
<div class="flex gap-2">
<button onclick={() => (banTarget = null)} class="btn btn-secondary btn-sm flex-1"
>Abbrechen</button
>
<button onclick={confirmBan} disabled={banSubmitting} class="btn btn-danger btn-sm flex-1">
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
</button>
</div>
{/if}
</Modal>
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
<!-- Header -->
<div
class="border-b border-gray-200 bg-white pt-[env(safe-area-inset-top)] dark:border-gray-800 dark:bg-gray-900"
>
<div class="mx-auto flex max-w-3xl items-center gap-3 px-4 py-4">
<IconButton label="Zurück" onclick={() => goto('/account')} class="shrink-0">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
</IconButton>
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Admin-Dashboard</h1>
</div>
</div>
<!-- Inner tab bar -->
<div
class="sticky top-0 z-20 overflow-x-auto border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
>
<div class="mx-auto flex max-w-3xl min-w-max">
{#each Object.entries(TAB_LABELS) as [tab, label] (tab)}
<button
onclick={() => (activeTab = tab as AdminTab)}
class="px-5 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
{activeTab === tab
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
>
{label}
</button>
{/each}
</div>
</div>
<div class="mx-auto max-w-3xl p-4">
{#if loading}
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
{:else if error}
<!-- A retry, not just a verdict: `reload()` fails for transient reasons (wifi blip),
and without this the only way out is a browser reload — not obvious, and not even
reachable, in an installed PWA with no URL bar. -->
<div
role="alert"
class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
>
<p>{error}</p>
<button onclick={() => void reload()} class="btn btn-secondary btn-sm mt-3">
Erneut laden
</button>
</div>
{:else}
<!-- ── Stats tab ────────────────────────────────────────────────── -->
{#if activeTab === 'stats'}
<div class="space-y-3">
{#if stats}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="card p-4 text-center">
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
{stats.user_count}
</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Gäste</p>
</div>
<div class="card p-4 text-center">
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
{stats.upload_count}
</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
</div>
{#if $commentsEnabled}
<div class="card p-4 text-center">
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
{stats.comment_count}
</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Kommentare</p>
</div>
{/if}
<div class="card p-4 text-center">
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
{diskPct(stats)} %
</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Speicher</p>
</div>
</div>
<!-- Disk bar -->
<div class="card p-5">
<div
class="mb-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
>
<span>Speicherauslastung</span>
<span
>{formatBytes(stats.disk_used_bytes)} / {formatBytes(
stats.disk_total_bytes
)}</span
>
</div>
<div class="h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full rounded-full transition-all {diskPct(stats) >= 90
? 'bg-red-500'
: diskPct(stats) >= 75
? 'bg-amber-500'
: 'bg-blue-500'}"
style="width: {diskPct(stats)}%"
></div>
</div>
<p class="mt-1.5 text-xs text-gray-400 dark:text-gray-500">
{formatBytes(stats.disk_free_bytes)} frei
</p>
</div>
{/if}
</div>
<!-- ── Config tab ───────────────────────────────────────────────── -->
{:else if activeTab === 'config'}
<div class="relative space-y-3 pb-20">
<!-- ── Colour theme ─────────────────────────────────────────── -->
<div class="card">
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
<h3
class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
Farbschema
</h3>
</div>
<div class="space-y-4 px-5 py-4">
<p class="text-xs text-gray-500 dark:text-gray-400">
Wähle eine Vorlage oder eigene Farben. Die Vorschau wird sofort angewendet;
„Speichern“ übernimmt sie für alle Gäste. Neutrale Töne (Grau/Silber) bleiben
bewusst unverändert.
</p>
<!-- Preset swatches -->
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each PRESETS as preset (preset.id)}
<button
type="button"
onclick={() => selectPreset(preset.id)}
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {themePreset ===
preset.id
? 'border-primary-400 bg-primary-50 dark:bg-primary-950/40'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'}"
aria-pressed={themePreset === preset.id}
>
<span
class="h-5 w-5 shrink-0 rounded-full ring-1 ring-black/10"
style="background-color: {preset.primary}"
></span>
<span class="min-w-0 truncate text-gray-800 dark:text-gray-200"
>{preset.label}</span
>
</button>
{/each}
<button
type="button"
onclick={() => selectPreset('custom')}
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {themePreset ===
'custom'
? 'border-primary-400 bg-primary-50 dark:bg-primary-950/40'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'}"
aria-pressed={themePreset === 'custom'}
>
<span
class="h-5 w-5 shrink-0 rounded-full ring-1 ring-black/10"
style="background: conic-gradient(from 0deg, #b0506a, #c6a24a, #5c7a5c, #3f5f8a, #b0506a)"
></span>
<span class="text-gray-800 dark:text-gray-200">Eigene</span>
</button>
</div>
<!-- Custom colour pickers -->
{#if themePreset === 'custom'}
<div class="grid grid-cols-2 gap-3">
<label class="text-sm">
<span class="mb-1 block font-medium text-gray-700 dark:text-gray-300"
>Primär</span
>
<span class="flex items-center gap-2">
<input
type="color"
bind:value={themePrimary}
oninput={onCustomColor}
class="h-9 w-9 cursor-pointer rounded border border-gray-200 bg-transparent p-0.5 dark:border-gray-700"
aria-label="Primärfarbe"
/>
<input
type="text"
bind:value={themePrimary}
oninput={onCustomColor}
class="input font-mono text-sm uppercase"
maxlength="7"
/>
</span>
</label>
<label class="text-sm">
<span class="mb-1 block font-medium text-gray-700 dark:text-gray-300"
>Akzent</span
>
<span class="flex items-center gap-2">
<input
type="color"
bind:value={themeAccent}
oninput={onCustomColor}
class="h-9 w-9 cursor-pointer rounded border border-gray-200 bg-transparent p-0.5 dark:border-gray-700"
aria-label="Akzentfarbe"
/>
<input
type="text"
bind:value={themeAccent}
oninput={onCustomColor}
class="input font-mono text-sm uppercase"
maxlength="7"
/>
</span>
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Die gewählte Farbe ist der Button-/Signalton; hellere und dunklere Abstufungen
werden automatisch daraus abgeleitet. Für gute Lesbarkeit reicht ein kräftiger,
eher dunkler Ton (weiße Button-Schrift).
</p>
{/if}
<!-- Mini live preview -->
<div
class="flex flex-wrap items-center gap-2 rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50"
>
<button type="button" class="btn btn-primary btn-sm" tabindex="-1"
>Beispiel-Button</button
>
<span class="chip chip-active" style="pointer-events: none">#hochzeit</span>
<span class="text-sm font-medium text-primary-600 dark:text-primary-400"
>Akzenttext</span
>
</div>
<div class="flex gap-2">
<button
type="button"
onclick={saveTheme}
disabled={themeSaving || !themeDirty}
class="btn btn-primary btn-sm"
>
{themeSaving ? 'Wird gespeichert…' : 'Farbschema speichern'}
</button>
<button
type="button"
onclick={resetTheme}
class="btn btn-secondary btn-sm"
disabled={themePreset === 'champagne-gold'}
>
Zurücksetzen
</button>
</div>
</div>
</div>
{#each CONFIG_GROUPS as group (group.title)}
<div class="card">
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
<h3
class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
{group.title}
</h3>
</div>
<div class="space-y-4 px-5 py-4">
{#each group.fields as field (field.key)}
<div>
{#if field.kind === 'bool'}
<label class="flex cursor-pointer items-start gap-3" for={field.key}>
<input
id={field.key}
type="checkbox"
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
checked={isTrue(configDraft[field.key])}
onchange={() => toggleBool(field.key)}
/>
<div class="min-w-0">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
>{field.label}</span
>
{#if field.hint}
<p class="text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
{/if}
</div>
</label>
{:else if field.kind === 'text'}
<label
for={field.key}
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>{field.label}</label
>
<textarea
id={field.key}
rows="6"
bind:value={configDraft[field.key]}
class="input resize-none font-mono text-sm"
></textarea>
{#if field.hint}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
{/if}
{:else}
<label
for={field.key}
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>{field.label}</label
>
<input
id={field.key}
type="number"
step="any"
min="0"
inputmode="decimal"
bind:value={configDraft[field.key]}
class="input text-sm"
/>
{#if field.hint}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
{/if}
{/if}
</div>
{/each}
</div>
</div>
{/each}
<!-- Sticky save button -->
<div
class="sticky bottom-0 -mx-4 border-t border-gray-100 bg-white px-5 py-3 dark:border-gray-800 dark:bg-gray-900 sm:mx-0 sm:rounded-b-xl"
>
<button onclick={saveConfig} disabled={saving} class="btn btn-primary btn-block">
{saving ? 'Wird gespeichert…' : 'Speichern'}
</button>
</div>
</div>
<!-- ── Export tab ───────────────────────────────────────────────── -->
{:else if activeTab === 'export'}
<div class="space-y-3">
<!-- Gallery release — deliberately the same controls, gating and wording as the
host dashboard's Event-Einstellungen block. Two independently written UIs for
one operation is how they drifted apart in the first place. -->
<div class="card p-5">
<h3 class="mb-3 font-semibold text-gray-900 dark:text-gray-100">Galerie</h3>
<button
onclick={() =>
(confirmAction = {
title: 'Galerie freigeben?',
message:
// See the identical fix on the host page: the release is reversible via
// "Uploads wieder öffnen", and it locks uploads at the same time.
'Uploads werden dabei gesperrt, und Gäste können alle Fotos herunterladen. Rückgängig machbar: „Uploads wieder öffnen“ zieht die Freigabe zurück.',
confirmLabel: 'Freigeben',
tone: 'danger',
run: releaseGallery
})}
disabled={exportInfo?.released}
class="btn btn-primary btn-sm"
>
{exportInfo?.released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
</button>
<!-- Live keepsake status. After release the ZIP/HTML still take minutes to build
and guest downloads 404 until they're done, so an admin who can't see this
announces "released!" while every download still fails. -->
{#if exportInfo?.released}
<div class="mt-3 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-gray-800/60">
{#if exportGenerating}
<p class="font-medium text-amber-700 dark:text-amber-300">
Keepsake wird erstellt… {exportProgress}%
</p>
<div
class="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"
>
<div
class="h-full rounded-full bg-amber-500 transition-all"
style="width: {exportProgress}%"
></div>
</div>
{:else if exportReady}
<p class="flex items-center justify-between gap-2">
<span class="font-medium text-green-700 dark:text-green-300"
>Keepsake ist bereit.</span
>
<a href="/export" class="font-medium text-blue-600 underline dark:text-blue-400"
>Herunterladen</a
>
</p>
{:else}
<p class="text-red-700 dark:text-red-300">Keepsake-Erstellung fehlgeschlagen.</p>
<!-- The reason, not just the verdict: the common failure is "not enough disk",
and retrying without freeing space fails identically forever. The backend
already wrote a message naming the numbers. -->
{#if exportError}
<p class="mt-1 text-red-700/80 dark:text-red-300/80">{exportError}</p>
{/if}
{/if}
<!-- ONE button, mounted in every state — deliberately OUTSIDE the branches
above. Those branches are SSE-driven, and `rebuildExport` itself makes the
backend broadcast `export-progress` at 0% immediately: a button living
inside a branch would UNMOUNT ITSELF between mousedown and mouseup, and
Chromium then fires no `click` at all. So the button's existence is
invariant; only its label and `disabled` change. (Same reasoning as the
host page — see the longer note there.) -->
<button
onclick={() => {
// Rebuilding a READY keepsake is disruptive (guests tapping Herunterladen
// get nothing until it finishes), so it asks first. A FAILED keepsake has
// nothing to lose and retries immediately.
if (exportReady) {
confirmAction = {
title: 'Keepsake neu erstellen?',
message:
'Das Keepsake wird aus dem aktuellen Stand der Galerie neu erzeugt. ' +
'Während der Erstellung können Gäste es nicht herunterladen.',
confirmLabel: 'Neu erstellen',
tone: 'danger',
run: rebuildExport
};
} else {
void rebuildExport();
}
}}
disabled={rebuilding || exportGenerating}
data-testid="export-rebuild"
class="mt-2 inline-flex min-h-11 items-center rounded-lg px-3 py-1.5 text-xs font-medium transition disabled:opacity-50
{exportReady
? 'text-gray-500 underline dark:text-gray-400'
: 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400'}"
>
{rebuilding
? 'Wird gestartet…'
: exportReady
? 'Neu erstellen'
: 'Erneut versuchen'}
</button>
</div>
{/if}
</div>
<!-- Export jobs -->
<div class="card p-5">
<div class="mb-4 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Export-Jobs</h3>
<button
onclick={refreshExportJobs}
disabled={exportJobsRefreshing}
class="text-xs text-blue-600 hover:underline disabled:opacity-50 dark:text-blue-400"
>
{exportJobsRefreshing ? 'Lädt…' : 'Aktualisieren'}
</button>
</div>
{#if exportJobs.length === 0}
<p class="text-sm text-gray-400 dark:text-gray-500">Noch keine Export-Jobs.</p>
{:else}
<div class="space-y-3">
{#each exportJobs as job (job.type)}
<div class="rounded-lg border border-gray-100 p-3 dark:border-gray-700">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
>{jobLabel(job.type)}</span
>
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadgeClass(
job.status
)}"
>
{statusLabel(job.status)}
</span>
</div>
{#if job.status === 'running'}
<div class="mt-2">
<div
class="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400"
>
<span>Fortschritt</span><span>{job.progress_pct} %</span>
</div>
<div
class="h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"
>
<div
class="h-full rounded-full bg-blue-500 transition-all"
style="width: {job.progress_pct}%"
></div>
</div>
</div>
{/if}
{#if job.error_message}
<p class="mt-1 text-xs text-red-600 dark:text-red-400">{job.error_message}</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- ── Nutzer tab ───────────────────────────────────────────────── -->
{:else if activeTab === 'users'}
<div class="card overflow-hidden">
<!-- Search -->
<div class="p-4">
<div
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-900"
>
<svg
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
type="search"
placeholder="Nutzer suchen…"
bind:value={userSearch}
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
</div>
{#if filteredUsers.length === 0}
<p class="px-5 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
Keine Treffer.
</p>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{#each filteredUsers as user (user.id)}
<div class="flex items-center gap-3 px-5 py-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium text-gray-900 dark:text-gray-100"
>{user.display_name}</span
>
{#if user.role === 'host'}
<span class="badge badge-primary">Host</span>
{:else if user.role === 'admin'}
<span class="badge badge-gold">Admin</span>
{/if}
{#if user.is_banned}
<span class="badge badge-danger">Gesperrt</span>
{/if}
</div>
<p class="text-xs text-gray-400 dark:text-gray-500">
{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(
user.total_upload_bytes
)}
</p>
</div>
<!-- 44 px targets and a wider gap, not the ~28 px/6 px this had: these buttons
sit shoulder to shoulder on a phone and a mis-tap here bans the wrong guest. -->
<div class="flex shrink-0 flex-wrap justify-end gap-2">
{#if user.role !== 'admin'}
{#if user.is_banned}
<button
onclick={() =>
(confirmAction = {
title: 'Sperre aufheben?',
message: unbanMessage(user),
confirmLabel: 'Entsperren',
tone: 'default',
run: () => unban(user)
})}
class="inline-flex min-h-11 items-center rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
>
Entsperren
</button>
{:else}
{#if user.role === 'guest'}
<button
onclick={() =>
(confirmAction = {
title: 'Zum Host befördern?',
message: `${user.display_name} erhält Host-Rechte: sperren, PIN zurücksetzen und Galerie verwalten. Das lässt sich nur durch Degradieren rückgängig machen.`,
confirmLabel: 'Befördern',
tone: 'default',
run: () => promoteToHost(user)
})}
class="inline-flex min-h-11 items-center rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60"
>
Host
</button>
{/if}
{#if user.role === 'host'}
<button
onclick={() =>
(confirmAction = {
title: 'Zum Gast degradieren?',
message: `${user.display_name} verliert alle Host-Rechte.`,
confirmLabel: 'Degradieren',
tone: 'danger',
run: () => demoteToGuest(user)
})}
class="inline-flex min-h-11 items-center rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
>
Degradieren
</button>
{/if}
{#if canResetPinFor(user)}
<button
onclick={() => askResetPin(user)}
class="inline-flex min-h-11 items-center rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60"
>
PIN zurücksetzen
</button>
{/if}
<button
onclick={() => openBanModal(user)}
class="inline-flex min-h-11 items-center rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60"
>
Sperren
</button>
{/if}
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</div>
</div>