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>
266 lines
8.2 KiB
Svelte
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>
|