Both are the same class as the autocomplete bug fixed in bf68bc0: a handler must never
destroy the DOM node that is being clicked.
feed grid tiles: VirtualFeed keys tiles by upload.id but slices the uploads array
POSITIONALLY (`uploads.slice(i*COLS, …)`). A `new-upload` SSE prepends to the array, so
every tile shifts one slot and each row's keyed {#each} sees a new set of ids — Svelte
destroys and recreates the tile nodes. At a party, where photos stream in continuously, a
guest mid-tap can have the node torn out (tap swallowed) or, worse, like/open a DIFFERENT
photo that slid under their finger. Grid now buffers live arrivals behind the existing
"neue Beiträge" pill; list view is keyed at the top level and stays live.
host rebuild button: it lived inside an SSE-driven {#if exportGenerating}{:else if
exportReady}{:else} block, and rebuildExport() makes the backend broadcast export-progress
immediately — so pressing it flipped the branch and unmounted the button mid-click, on the
one screen whose purpose is recovering a broken keepsake. One button now stays mounted
across all three states; only its label and disabled vary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
809 lines
33 KiB
Svelte
809 lines
33 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import { getToken, getRole, getUserId } from '$lib/auth';
|
|
import { api } from '$lib/api';
|
|
import type { MeContextDto } from '$lib/types';
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { 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';
|
|
|
|
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;
|
|
}
|
|
|
|
interface EventStatus {
|
|
name: string;
|
|
is_active: boolean;
|
|
uploads_locked: boolean;
|
|
export_released: boolean;
|
|
}
|
|
|
|
interface PinResetRequest {
|
|
id: string;
|
|
user_id: string;
|
|
display_name: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface ExportJob {
|
|
status: string;
|
|
progress_pct: number;
|
|
}
|
|
interface ExportStatusDto {
|
|
released: boolean;
|
|
zip: ExportJob | null;
|
|
html: ExportJob | null;
|
|
}
|
|
|
|
let event = $state<EventStatus | null>(null);
|
|
let users = $state<UserSummary[]>([]);
|
|
let pinResetRequests = $state<PinResetRequest[]>([]);
|
|
let exportInfo = $state<ExportStatusDto | null>(null);
|
|
let rebuilding = $state(false);
|
|
let loading = $state(true);
|
|
let error = $state<string | null>(null);
|
|
|
|
// Live keepsake state for the host dashboard: are both halves done, and if not, how far.
|
|
let exportReady = $derived(
|
|
exportInfo?.zip?.status === 'done' && exportInfo?.html?.status === 'done'
|
|
);
|
|
// The keepsake needs BOTH halves, so EITHER failing means the whole thing failed — use
|
|
// `&&` (not `||`), else a one-half failure stays stuck on "wird erstellt…" forever and the
|
|
// host never sees the "re-release" recovery hint.
|
|
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)
|
|
);
|
|
|
|
// SSE unsubscribers, torn down on destroy.
|
|
let sseOff: Array<() => void> = [];
|
|
|
|
// Collapsible section state
|
|
let statsOpen = $state(true);
|
|
let settingsOpen = $state(true);
|
|
let usersOpen = $state(true);
|
|
|
|
// User search
|
|
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 modal state. `pinModal` holds the freshly-issued plaintext PIN; it is
|
|
// shown once and forgotten on close.
|
|
let pinResetTarget = $state<UserSummary | null>(null);
|
|
let pinResetSubmitting = $state(false);
|
|
let pinModal = $state<{ name: string; pin: string } | null>(null);
|
|
|
|
const myRole = getRole();
|
|
const myUserId = getUserId();
|
|
|
|
// Generic confirm-then-run for the irreversible / privilege-changing actions
|
|
// (promote, demote, unban, release gallery) that previously fired on one tap.
|
|
// Reuses the shared ConfirmSheet; the wrapped fns keep their own toast/reload.
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Mirrors the backend authorisation rules shared by ban / unban / PIN-reset / set-role:
|
|
* a plain host may only act on GUESTS; only an admin may act on a host; nobody may act on
|
|
* an admin. Used to hide moderation buttons that would always 403 — a non-admin host must
|
|
* not see Sperren/Degradieren/PIN on a peer-host row (they'd tap it and get a bare 403).
|
|
*/
|
|
function canModerate(target: UserSummary): boolean {
|
|
if (target.role === 'admin') return false;
|
|
if (myRole === 'admin') return true;
|
|
if (myRole === 'host') return target.role === 'guest';
|
|
return false;
|
|
}
|
|
|
|
onMount(async () => {
|
|
const token = getToken();
|
|
if (!token) {
|
|
goto('/join');
|
|
return;
|
|
}
|
|
// Trust the *live* role, not the JWT claim: a mid-session demote leaves a
|
|
// stale 'host' in the token, but the backend now 403s every host call. Fetch
|
|
// the current role and bounce a demoted host to the feed instead of leaving
|
|
// them on a dashboard that errors on load.
|
|
try {
|
|
const ctx = await api.get<MeContextDto>('/me/context');
|
|
if (ctx.role !== 'host' && ctx.role !== 'admin') {
|
|
goto('/feed');
|
|
return;
|
|
}
|
|
} catch {
|
|
// Expired/invalid session (api.ts cleared it) — send them to re-auth.
|
|
goto('/join');
|
|
return;
|
|
}
|
|
await reload();
|
|
|
|
// The awaits above mean the component can already be destroyed by the time we get
|
|
// here (user navigated away mid-load). Svelte doesn't cancel an async onMount, so
|
|
// without this guard onDestroy would have run with an empty `sseOff` and we'd leak
|
|
// these handlers into the module-global SSE map forever.
|
|
if (destroyed) return;
|
|
|
|
// Live updates so the dashboard doesn't need a manual reload:
|
|
// - pin-reset-requested: a guest just asked for a reset → refresh the badge/list.
|
|
// - pin-reset: some host resolved a reset → drop it from the list (two-host race:
|
|
// the other host's stale row disappears instead of yielding a conflicting PIN).
|
|
// - export-progress/-available: keepsake generation moves → refresh the status line.
|
|
sseOff = [
|
|
onSseEvent('pin-reset-requested', () => void refreshPinRequests()),
|
|
onSseEvent('pin-reset', () => void refreshPinRequests()),
|
|
onSseEvent('export-progress', () => void refreshExportStatus()),
|
|
onSseEvent('export-available', () => void refreshExportStatus())
|
|
];
|
|
});
|
|
|
|
let destroyed = false;
|
|
onDestroy(() => {
|
|
destroyed = true;
|
|
for (const off of sseOff) off();
|
|
});
|
|
|
|
async function reload() {
|
|
loading = true;
|
|
error = null;
|
|
try {
|
|
[event, users, pinResetRequests] = await Promise.all([
|
|
api.get<EventStatus>('/host/event'),
|
|
api.get<UserSummary[]>('/host/users'),
|
|
api.get<PinResetRequest[]>('/host/pin-reset-requests')
|
|
]);
|
|
} catch (e: unknown) {
|
|
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
// Export status is a secondary widget — fetch it OUTSIDE the all-or-nothing block above
|
|
// (and error-swallowing) so a transient /export/status failure can't blank the whole
|
|
// dashboard (users, moderation, settings).
|
|
void refreshExportStatus();
|
|
}
|
|
|
|
/** Refetch just the pending PIN-reset requests (live badge; avoids a full-page reload). */
|
|
async function refreshPinRequests() {
|
|
try {
|
|
pinResetRequests = await api.get<PinResetRequest[]>('/host/pin-reset-requests');
|
|
} catch {
|
|
/* non-fatal — the next manual reload picks it up */
|
|
}
|
|
}
|
|
|
|
/** Refetch just the keepsake export status (live progress/ready). */
|
|
async function refreshExportStatus() {
|
|
try {
|
|
exportInfo = await api.get<ExportStatusDto>('/export/status');
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
async function resetPinForRequest(req: PinResetRequest) {
|
|
try {
|
|
const res = await api.post<{ pin: string }>(`/host/users/${req.user_id}/pin-reset`);
|
|
pinModal = { name: req.display_name, pin: res.pin };
|
|
await reload();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
}
|
|
}
|
|
|
|
async function dismissPinRequest(req: PinResetRequest) {
|
|
try {
|
|
await api.delete(`/host/pin-reset-requests/${req.id}`);
|
|
pinResetRequests = pinResetRequests.filter((r) => r.id !== req.id);
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
}
|
|
}
|
|
|
|
async function reopenEvent() {
|
|
try {
|
|
await api.post('/host/event/open');
|
|
toast('Uploads wurden wieder geöffnet.', 'success');
|
|
await reload();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
}
|
|
}
|
|
|
|
function toggleEventLock() {
|
|
if (!event) return;
|
|
if (event.uploads_locked) {
|
|
// Reopening after a release INVALIDATES the published keepsake: it clears the
|
|
// release + ready flags, so every guest's download 404s until the host re-releases.
|
|
// That's destructive and easy to trigger by accident ("let a few more photos in"),
|
|
// so gate it behind a danger confirm — but a plain lock (not yet released) reopens
|
|
// with no ceremony.
|
|
if (event.export_released) {
|
|
confirmAction = {
|
|
title: 'Galerie-Freigabe zurücknehmen?',
|
|
message:
|
|
'Wenn du die Uploads wieder öffnest, wird die veröffentlichte Galerie zurückgezogen. ' +
|
|
'Gäste können das Keepsake erst nach einer erneuten Freigabe wieder herunterladen.',
|
|
confirmLabel: 'Wieder öffnen',
|
|
tone: 'danger',
|
|
run: reopenEvent
|
|
};
|
|
return;
|
|
}
|
|
void reopenEvent();
|
|
} else {
|
|
void (async () => {
|
|
try {
|
|
await api.post('/host/event/close');
|
|
toast('Uploads wurden gesperrt.', 'success');
|
|
await reload();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
}
|
|
})();
|
|
}
|
|
}
|
|
|
|
async function releaseGallery() {
|
|
try {
|
|
await api.post('/host/gallery/release');
|
|
toast('Galerie wurde freigegeben. Export wird vorbereitet…', 'success');
|
|
await reload();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
// Release marks `export_released_at` before enqueuing the workers; if that second
|
|
// step errored the event is already released, so reconcile the UI (otherwise the
|
|
// button still reads "Galerie freigeben" and a retry just 409s "bereits freigegeben").
|
|
await reload();
|
|
}
|
|
}
|
|
|
|
// The escape hatch for a keepsake that is failed or stale. Without this the only recovery is
|
|
// reopening uploads (which unlocks the gallery to every guest and retracts the release) or a
|
|
// container restart — neither of which a host at 2am can reasonably be asked to do.
|
|
async function rebuildExport() {
|
|
rebuilding = true;
|
|
try {
|
|
await api.post('/host/export/rebuild', {});
|
|
toast('Keepsake wird neu erstellt…', 'success');
|
|
await refreshExportStatus();
|
|
} 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;
|
|
await reload();
|
|
} 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');
|
|
await reload();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
}
|
|
}
|
|
|
|
async function promoteToHost(user: UserSummary) {
|
|
try {
|
|
await api.patch(`/host/users/${user.id}/role`, { role: 'host' });
|
|
toast(`${user.display_name} ist jetzt Host.`, 'success');
|
|
await reload();
|
|
} 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');
|
|
await reload();
|
|
} 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');
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
</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
|
|
? `Eine neue PIN für ${pinResetTarget.display_name} wird erzeugt. Die alte PIN funktioniert dann nicht mehr.`
|
|
: ''}
|
|
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="host-pin-modal-title" onClose={() => (pinModal = null)}>
|
|
{#if pinModal}
|
|
<h2 id="host-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="w-full rounded-lg bg-blue-600 py-2 text-sm font-semibold text-white hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400"
|
|
>
|
|
Schließen
|
|
</button>
|
|
{/if}
|
|
</Modal>
|
|
|
|
<!-- Ban modal — ban always hides now, so this is a plain confirm (no checkbox). -->
|
|
<Modal open={banTarget !== null} titleId="host-ban-modal-title" onClose={() => (banTarget = null)}>
|
|
{#if banTarget}
|
|
<h2 id="host-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">
|
|
<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus
|
|
Galerie, Diashow und Export, und Hochladen, Liken und Kommentieren werden blockiert. Der
|
|
Lesezugriff (Feed ansehen, Keepsake herunterladen) bleibt bestehen. Rückgängig machbar über „Entsperren“.
|
|
</p>
|
|
<div class="flex gap-2">
|
|
<button
|
|
onclick={() => (banTarget = null)}
|
|
class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onclick={confirmBan}
|
|
disabled={banSubmitting}
|
|
class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 active:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400"
|
|
>
|
|
{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>
|
|
<div class="min-w-0">
|
|
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Host-Dashboard</h1>
|
|
{#if event}
|
|
<p class="truncate text-sm text-gray-500 dark:text-gray-400">{event.name}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mx-auto max-w-3xl space-y-3 p-4">
|
|
{#if loading}
|
|
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
|
|
{:else if error}
|
|
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">{error}</div>
|
|
{:else if event}
|
|
|
|
<!-- ── PIN-Reset-Anfragen ──────────────────────────────────────── -->
|
|
{#if pinResetRequests.length > 0}
|
|
<div class="overflow-hidden rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
|
|
<div class="border-b border-amber-200 px-5 py-3 dark:border-amber-900">
|
|
<h2 class="font-semibold text-amber-900 dark:text-amber-200">
|
|
PIN vergessen — {pinResetRequests.length} Anfrage{pinResetRequests.length === 1 ? '' : 'n'}
|
|
</h2>
|
|
<p class="mt-0.5 text-xs text-amber-700 dark:text-amber-400">
|
|
Prüfe die Identität, bevor du eine PIN zurücksetzt.
|
|
</p>
|
|
</div>
|
|
<ul class="divide-y divide-amber-200 dark:divide-amber-900">
|
|
{#each pinResetRequests as req (req.id)}
|
|
<li class="flex items-center justify-between gap-3 px-5 py-3">
|
|
<span class="min-w-0 truncate font-medium text-amber-900 dark:text-amber-100">{req.display_name}</span>
|
|
<div class="flex shrink-0 gap-2">
|
|
<button
|
|
onclick={() => dismissPinRequest(req)}
|
|
class="rounded-lg border border-amber-300 px-3 py-1.5 text-sm text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-200 dark:hover:bg-amber-900/40"
|
|
>
|
|
Ablehnen
|
|
</button>
|
|
<button
|
|
onclick={() => resetPinForRequest(req)}
|
|
class="rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-400"
|
|
>
|
|
PIN zurücksetzen
|
|
</button>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- ── Statistiken ─────────────────────────────────────────────── -->
|
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
|
<button
|
|
onclick={() => (statsOpen = !statsOpen)}
|
|
aria-expanded={statsOpen}
|
|
class="flex w-full items-center justify-between px-5 py-4"
|
|
>
|
|
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Statistiken</h2>
|
|
<svg
|
|
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {statsOpen ? 'rotate-180' : ''}"
|
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
</button>
|
|
<div class="overflow-hidden transition-[max-height] duration-200 {statsOpen ? 'max-h-[500px]' : 'max-h-0'}">
|
|
<div class="grid grid-cols-2 gap-3 border-t border-gray-100 p-4 dark:border-gray-700 sm:grid-cols-4">
|
|
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{users.length}</p>
|
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Teilnehmer</p>
|
|
</div>
|
|
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{users.reduce((s, u) => s + u.upload_count, 0)}</p>
|
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
|
|
</div>
|
|
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
|
<p class="text-2xl font-bold {event.uploads_locked ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}">
|
|
{event.uploads_locked ? 'Gesperrt' : 'Offen'}
|
|
</p>
|
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
|
|
</div>
|
|
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
|
<p class="text-2xl font-bold {event.export_released ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-gray-500'}">
|
|
{event.export_released ? 'Ja' : 'Nein'}
|
|
</p>
|
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Freigegeben</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Event-Einstellungen ─────────────────────────────────────── -->
|
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
|
<button
|
|
onclick={() => (settingsOpen = !settingsOpen)}
|
|
aria-expanded={settingsOpen}
|
|
class="flex w-full items-center justify-between px-5 py-4"
|
|
>
|
|
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Event-Einstellungen</h2>
|
|
<svg
|
|
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {settingsOpen ? 'rotate-180' : ''}"
|
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
</button>
|
|
<div class="overflow-hidden transition-[max-height] duration-200 {settingsOpen ? 'max-h-[500px]' : 'max-h-0'}">
|
|
<div class="flex flex-wrap gap-3 border-t border-gray-100 p-5 dark:border-gray-700">
|
|
<button
|
|
onclick={toggleEventLock}
|
|
class="rounded-lg px-4 py-2 text-sm font-medium transition
|
|
{event.uploads_locked ? 'bg-green-600 text-white hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-400' : 'bg-amber-500 text-white hover:bg-amber-600 dark:bg-amber-500 dark:hover:bg-amber-400'}"
|
|
>
|
|
{event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'}
|
|
</button>
|
|
<button
|
|
onclick={() => (confirmAction = {
|
|
title: 'Galerie freigeben?',
|
|
message: 'Gäste können dann alle Fotos herunterladen. Das kann nicht rückgängig gemacht werden.',
|
|
confirmLabel: 'Freigeben',
|
|
tone: 'danger',
|
|
run: releaseGallery
|
|
})}
|
|
disabled={event.export_released}
|
|
class="rounded-lg px-4 py-2 text-sm font-medium transition
|
|
{event.export_released ? 'cursor-default bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500' : 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400'}"
|
|
>
|
|
{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Live keepsake status: after release the ZIP/HTML still take time to build, and
|
|
downloads 404 until they're done — surface it so a host doesn't announce
|
|
"released!" while guest downloads still fail. -->
|
|
{#if event.export_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">Zum Download</a>
|
|
</p>
|
|
{:else}
|
|
<p class="text-red-700 dark:text-red-300">Keepsake-Erstellung fehlgeschlagen.</p>
|
|
{/if}
|
|
|
|
<!-- ONE button, mounted in every state — deliberately OUTSIDE the branches above.
|
|
Those branches are driven by SSE (`export-progress` / `export-available`), and
|
|
`rebuildExport` itself makes the backend broadcast `export-progress` at 0%
|
|
immediately. If the button lived inside a branch, activating it would flip
|
|
`exportGenerating` and UNMOUNT THE BUTTON BEING PRESSED — and a worker tick
|
|
landing between mousedown and mouseup would do the same unprompted. Chromium
|
|
fires no `click` when the element dies mid-sequence, so the host would tap
|
|
"Erneut versuchen" and nothing at all would happen, on the one screen whose
|
|
entire purpose is recovering a broken keepsake. (Same class of bug as the feed
|
|
autocomplete: never let a handler destroy the node that is being clicked.)
|
|
|
|
So the button's EXISTENCE is invariant; only its label and `disabled` change. -->
|
|
<button
|
|
onclick={() => {
|
|
// Rebuilding a READY keepsake is disruptive — guests who tap Download during
|
|
// the rebuild get nothing until it finishes — so it gets a confirm. A FAILED
|
|
// keepsake has nothing to lose, so it 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 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>
|
|
</div>
|
|
|
|
<!-- ── Nutzerverwaltung ───────────────────────────────────────── -->
|
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
|
<button
|
|
onclick={() => (usersOpen = !usersOpen)}
|
|
aria-expanded={usersOpen}
|
|
class="flex w-full items-center justify-between px-5 py-4"
|
|
>
|
|
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Nutzerverwaltung</h2>
|
|
<svg
|
|
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {usersOpen ? 'rotate-180' : ''}"
|
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
</button>
|
|
<div class="overflow-hidden transition-[max-height] duration-300 {usersOpen ? 'max-h-[9999px]' : 'max-h-0'}">
|
|
<div class="border-t border-gray-100 dark:border-gray-700">
|
|
<!-- Search -->
|
|
<div class="px-4 py-3">
|
|
<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}
|
|
<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="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">Host</span>
|
|
{:else if user.role === 'admin'}
|
|
<span class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200">Admin</span>
|
|
{/if}
|
|
{#if user.is_banned}
|
|
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/40 dark:text-red-200">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>
|
|
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
|
{#if user.role !== 'admin'}
|
|
{#if user.is_banned}
|
|
<!-- Only show Entsperren to someone allowed to act on this user (a plain
|
|
host can't unban a peer host — that's an admin-only action, F1). -->
|
|
{#if canModerate(user)}
|
|
<button
|
|
onclick={() => (confirmAction = {
|
|
title: 'Sperre aufheben?',
|
|
message: `${user.display_name} kann danach wieder hochladen, liken und kommentieren.`,
|
|
confirmLabel: 'Entsperren',
|
|
tone: 'default',
|
|
run: () => unban(user)
|
|
})}
|
|
class="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>
|
|
{/if}
|
|
{:else if user.id !== myUserId}
|
|
<!-- Never render target-actions (promote/demote/PIN/ban) on the
|
|
caller's own row: the backend rejects every self-action
|
|
(self-ban / self-demote / self-PIN) with a 400, so the button
|
|
would only ever fail. -->
|
|
{#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')}
|
|
<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="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' && myRole === 'admin'}
|
|
<!-- Only an admin may demote a host (F1). A plain host demoting a
|
|
peer host is a 403 on the backend, so the button is hidden. -->
|
|
<button
|
|
onclick={() => (confirmAction = {
|
|
title: 'Zum Gast degradieren?',
|
|
message: `${user.display_name} verliert alle Host-Rechte.`,
|
|
confirmLabel: 'Degradieren',
|
|
tone: 'danger',
|
|
run: () => demoteToGuest(user)
|
|
})}
|
|
class="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 canModerate(user)}
|
|
<button
|
|
onclick={() => askResetPin(user)}
|
|
class="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}
|
|
{#if canModerate(user)}
|
|
<button
|
|
onclick={() => openBanModal(user)}
|
|
class="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}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|