Files
EventSnap/frontend/src/routes/host/+page.svelte
fabi e79e020566 feat(eventsnap): UX review batch-3 — feed virtualization, a11y/UX fixes, shared primitives
Frontend
- Feed: DOM-windowing via @tanstack/svelte-virtual (new VirtualFeed). The window
  virtualizer keeps the sticky header, pull-to-refresh, infinite-scroll sentinel and
  bottom nav working. List = dynamic measured heights keyed by upload id +
  anchorTo:start so an SSE prepend doesn't jump a scrolled reader; grid = measured
  square rows. Drops the content-visibility band-aid and the old FeedGrid.
  measureElement(null) on row unmount prevents ResizeObserver retention; stable
  option callbacks + guarded setOptions avoid O(n) re-measure on like/comment patches.
- Global: viewport-fit=cover (activates safe-area insets), lang=de, PWA manifest +
  maskable icon + apple-touch metas, prefers-reduced-motion, safe-area-top headers.
- Feed reactivity: like/comment SSE patch the single card in place; upload-processed
  debounced in-place merge; IntersectionObserver leak fixed; shared 60s clock.
- CLS: list cards reserve the skeleton aspect box.
- Destructive actions (promote/demote/unban, gallery release) routed through
  ConfirmSheet (host + admin).
- Export OOM: streamed download via single-use ticket instead of res.blob().
- Shared primitives: IconButton (44px hit area), scrollLock action; UploadSheet a11y.
- Polish: api timeout + non-JSON guard, focus-trap offsetParent fix, pull-to-refresh
  passive:false + guards, diashow keyboard a11y, Toaster assertive errors, dark-mode
  gaps, aria-pressed on like/chip state, CameraCapture mic-on-video + retry,
  ConfirmSheet spinner, upload beforeunload warning, emoji->SVG icons.

Backend
- social.rs: like/comment SSE broadcasts now carry the fresh count so feed clients
  patch one card in place instead of refetching page 1.
- admin.rs / main.rs: supporting changes for the above.

Verified: svelte-check 0 errors, frontend build clean, /feed SSR 200. Runtime feed
scroll-feel validation still owed (documented in FOLLOWUPS.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:16:48 +02:00

531 lines
21 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getRole } from '$lib/auth';
import { api } from '$lib/api';
import { onMount } from 'svelte';
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;
}
let event = $state<EventStatus | null>(null);
let users = $state<UserSummary[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
// 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 banHideUploads = $state(false);
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();
// 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 backend `handlers::host::reset_user_pin` authorisation rules. */
function canResetPinFor(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();
const role = getRole();
if (!token || (role !== 'host' && role !== 'admin')) {
goto('/join');
return;
}
await reload();
});
async function reload() {
loading = true;
error = null;
try {
[event, users] = await Promise.all([
api.get<EventStatus>('/host/event'),
api.get<UserSummary[]>('/host/users')
]);
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
} finally {
loading = false;
}
}
async function toggleEventLock() {
if (!event) return;
try {
if (event.uploads_locked) {
await api.post('/host/event/open');
toast('Uploads wurden wieder geöffnet.', 'success');
} else {
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);
}
}
function openBanModal(user: UserSummary) {
banTarget = user;
banHideUploads = false;
}
async function confirmBan() {
if (!banTarget) return;
banSubmitting = true;
try {
await api.post(`/host/users/${banTarget.id}/ban`, { hide_uploads: banHideUploads });
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 — needs a checkbox so it's not a pure ConfirmSheet, but still gets the same a11y shell. -->
<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">
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
</p>
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<input
type="checkbox"
bind:checked={banHideUploads}
class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
</label>
<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}
<!-- ── 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">Gäste</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>
</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}
<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>
{:else}
{#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'}
<!-- Hosts may demote other Hosts (never themselves); backend enforces. -->
<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 canResetPinFor(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}
<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}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>