<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 (0–1)',
					kind: 'number',
					// "Toleranz (0–1)" 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>
