Files
EventSnap/frontend/src/routes/diashow/+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

266 lines
8.2 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { showBottomNav } from '$lib/ui-store';
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
import { onSseEvent } from '$lib/sse';
import { SlideQueue } from '$lib/diashow/queue';
import { transitions, findTransition } from '$lib/diashow/transitions';
import { acquireWakeLock, releaseWakeLock } from '$lib/diashow/wakelock';
import type { FeedUpload, FeedResponse } from '$lib/types';
const DWELL_OPTIONS = [3000, 6000, 10000];
let queue = new SlideQueue();
let current = $state<FeedUpload | null>(null);
let dwellMs = $state(6000);
let transitionId = $state('crossfade');
let paused = $state(false);
let showOverlay = $state(false);
let overlayHideTimer: ReturnType<typeof setTimeout> | null = null;
let advanceTimer: ReturnType<typeof setTimeout> | null = null;
const unsubs: Array<() => void> = [];
const transitionDef = $derived(findTransition(transitionId));
const mediaSrc = $derived(current ? pickMediaUrl($dataMode, current) : '');
const isVideo = $derived(current?.mime_type.startsWith('video/') ?? false);
function isEmpty(): boolean {
return queue.stats().known === 0;
}
function scheduleNext() {
clearTimer();
if (paused) return;
// Videos: advance on `ended` or after `max(dwell, 12s)` — whichever first.
const ms = isVideo ? Math.max(dwellMs, 12000) : dwellMs;
advanceTimer = setTimeout(advance, ms);
}
function clearTimer() {
if (advanceTimer) {
clearTimeout(advanceTimer);
advanceTimer = null;
}
}
function advance() {
const next = queue.next();
current = next;
if (current) scheduleNext();
}
async function loadInitial() {
try {
const feed = await api.get<FeedResponse>('/feed?limit=200');
queue.seed(feed.uploads);
advance();
} catch {
// Silent — placeholder stays shown
}
}
// `upload-processed` carries only `{ upload_id }`; we re-fetch from /feed to get the
// preview/thumbnail URLs that just became available. We deliberately do NOT listen
// to `new-upload` here — its payload arrives before compression finishes
// (preview_url is still null), and `SlideQueue.pushLive` dedupes by id, so the
// preview would never be picked up if we enqueued the pre-processed version first.
async function handleUploadProcessed(data: string) {
try {
const payload = JSON.parse(data) as { upload_id: string };
if (!payload.upload_id) return;
const feed = await api.get<FeedResponse>('/feed?limit=20');
const found = feed.uploads.find((u) => u.id === payload.upload_id);
if (found) {
queue.pushLive(found);
if (!current) advance();
}
} catch {
// ignore — silent recovery; the next event will retry
}
}
function handleUploadDeleted(data: string) {
try {
const payload = JSON.parse(data) as { upload_id: string };
const result = queue.remove(payload.upload_id, current?.id ?? null);
if (result.wasCurrent) advance();
} catch {
// ignore
}
}
function revealOverlay() {
showOverlay = true;
if (overlayHideTimer) clearTimeout(overlayHideTimer);
overlayHideTimer = setTimeout(() => (showOverlay = false), 4000);
}
// Manual toggle from the always-visible control button — stays open (no
// auto-hide) so keyboard users can tab through the controls without them
// vanishing mid-interaction.
function toggleOverlay() {
if (overlayHideTimer) clearTimeout(overlayHideTimer);
showOverlay = !showOverlay;
}
function togglePause() {
paused = !paused;
if (paused) {
clearTimer();
} else {
scheduleNext();
}
}
function exit() {
void goto('/feed');
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (showOverlay) {
showOverlay = false;
} else {
exit();
}
}
if (e.key === ' ') {
e.preventDefault();
togglePause();
}
}
onMount(() => {
showBottomNav.set(false);
void acquireWakeLock();
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
void loadInitial();
});
onDestroy(() => {
showBottomNav.set(true);
clearTimer();
if (overlayHideTimer) clearTimeout(overlayHideTimer);
void releaseWakeLock();
for (const unsub of unsubs) unsub();
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="fixed inset-0 z-[60] flex items-center justify-center bg-black text-white"
role="presentation"
onclick={revealOverlay}
>
{#if current}
{#key current.id + '|' + transitionDef.id}
<transitionDef.component
src={mediaSrc}
{isVideo}
durationMs={transitionDef.defaultDurationMs}
/>
{/key}
{:else if isEmpty()}
<div class="text-center">
<p class="text-2xl font-semibold">Noch keine Beiträge</p>
<p class="mt-2 text-white/60">Neue Beiträge erscheinen hier automatisch.</p>
</div>
{:else}
<div class="text-white/60">Lade…</div>
{/if}
<!-- Always-visible controls: keyboard-reachable, so the show is never a trap and
the controls are reachable without a pointer. Honours the notch. -->
<div
class="absolute right-0 top-0 z-10 flex gap-2 p-3"
style="padding-top: calc(env(safe-area-inset-top) + 0.75rem)"
>
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleOverlay(); }}
aria-label="Steuerung anzeigen"
aria-expanded={showOverlay}
class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />
</svg>
</button>
<button
type="button"
onclick={(e) => { e.stopPropagation(); exit(); }}
aria-label="Diashow beenden"
class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
>
<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="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
{#if showOverlay}
<div
class="absolute inset-x-0 bottom-0 flex flex-col gap-3 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-6 pb-10"
>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
onclick={togglePause}
class="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
>
{#if paused}
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
Fortsetzen
{:else}
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6 5h4v14H6zM14 5h4v14h-4z" /></svg>
Pause
{/if}
</button>
<label class="flex items-center gap-2 rounded-full bg-white/10 px-3 py-2 text-sm">
Dauer
<select
bind:value={dwellMs}
onchange={scheduleNext}
class="bg-transparent text-sm font-medium focus:outline-none"
>
{#each DWELL_OPTIONS as ms (ms)}
<option value={ms} class="bg-black">{ms / 1000} s</option>
{/each}
</select>
</label>
<label class="flex items-center gap-2 rounded-full bg-white/10 px-3 py-2 text-sm">
Übergang
<select bind:value={transitionId} class="bg-transparent text-sm font-medium focus:outline-none">
{#each transitions as t (t.id)}
<option value={t.id} class="bg-black">{t.label}</option>
{/each}
</select>
</label>
<button
type="button"
onclick={exit}
class="ml-auto inline-flex items-center gap-1.5 rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
Beenden
</button>
</div>
{#if current}
<p class="text-xs text-white/70">
{current.uploader_name}
{#if current.caption}· {current.caption}{/if}
</p>
{/if}
</div>
{/if}
</div>