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>
This commit is contained in:
fabi
2026-06-30 19:16:48 +02:00
parent bbec815854
commit e79e020566
42 changed files with 1245 additions and 328 deletions

View File

@@ -6,6 +6,7 @@
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;
@@ -56,6 +57,25 @@
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;
@@ -196,6 +216,17 @@
}
</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}
@@ -266,17 +297,13 @@
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
<!-- Header -->
<div class="border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<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">
<button
onclick={() => goto('/account')}
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
aria-label="Zurück"
>
<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>
</button>
</IconButton>
<div class="min-w-0">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Host-Dashboard</h1>
{#if event}
@@ -297,6 +324,7 @@
<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>
@@ -337,6 +365,7 @@
<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>
@@ -357,7 +386,13 @@
{event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'}
</button>
<button
onclick={releaseGallery}
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'}"
@@ -372,6 +407,7 @@
<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>
@@ -424,7 +460,13 @@
{#if user.role !== 'admin'}
{#if user.is_banned}
<button
onclick={() => unban(user)}
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
@@ -432,7 +474,13 @@
{:else}
{#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')}
<button
onclick={() => promoteToHost(user)}
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
@@ -441,7 +489,13 @@
{#if user.role === 'host'}
<!-- Hosts may demote other Hosts (never themselves); backend enforces. -->
<button
onclick={() => demoteToGuest(user)}
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