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

@@ -4,8 +4,7 @@
import { api } from '$lib/api';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import { onMount, onDestroy } from 'svelte';
import FeedGrid from '$lib/components/FeedGrid.svelte';
import FeedListCard from '$lib/components/FeedListCard.svelte';
import VirtualFeed from '$lib/components/VirtualFeed.svelte';
import HashtagChips from '$lib/components/HashtagChips.svelte';
import LightboxModal from '$lib/components/LightboxModal.svelte';
import OnboardingGuide from '$lib/components/OnboardingGuide.svelte';
@@ -28,6 +27,8 @@
let pullProgress = $state(0); // 01+ during the drag, 0 when idle
let selectedUpload = $state<FeedUpload | null>(null);
let sentinel: HTMLDivElement;
let feedObserver: IntersectionObserver | null = null;
let inPlaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let pendingDeleteId = $state<string | null>(null);
// ─────────────────────────────────────────────────────────────────────────
@@ -190,7 +191,10 @@
uploads = [upload, ...uploads];
} catch { /* ignore */ }
}),
onSseEvent('upload-processed', () => loadFeed(true)),
// A processed upload gains preview/thumbnail URLs. Coalesce bursts (bulk
// uploads fire one per file) into a single in-place merge so the feed
// neither hammers the server nor collapses to page 1 / loses scroll.
onSseEvent('upload-processed', () => scheduleInPlaceRefresh()),
onSseEvent('upload-deleted', (data) => {
try {
const payload = JSON.parse(data) as { upload_id: string };
@@ -198,8 +202,11 @@
if (selectedUpload?.id === payload.upload_id) selectedUpload = null;
} catch { /* ignore */ }
}),
onSseEvent('like-update', () => loadFeed(true)),
onSseEvent('new-comment', () => loadFeed(true)),
// Patch the single affected card in place from the SSE payload instead of
// refetching page 1 — a busy event fires these constantly and a full reload
// would yank every scrolled-down user back to the top on each reaction.
onSseEvent('like-update', (data) => patchCount(data, 'like_count')),
onSseEvent('new-comment', (data) => patchCount(data, 'comment_count')),
// Synthetic event from the SSE client after a foreground reconnect — merge
// any uploads + deletions we missed while the tab was hidden.
onSseEvent('feed-delta', (data) => {
@@ -219,21 +226,68 @@
);
if (sentinel) {
const observer = new IntersectionObserver(
feedObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && nextCursor && !loadingMore) loadMore();
},
{ rootMargin: '200px' }
);
observer.observe(sentinel);
feedObserver.observe(sentinel);
}
});
onDestroy(() => {
disconnectSse();
for (const unsub of unsubscribers) unsub();
feedObserver?.disconnect();
if (inPlaceRefreshTimer) clearTimeout(inPlaceRefreshTimer);
});
// Patch a single upload's like/comment count from an SSE payload without
// disturbing scroll position or the rest of the loaded feed.
function patchCount(data: string, field: 'like_count' | 'comment_count') {
try {
const payload = JSON.parse(data) as {
upload_id: string;
like_count?: number;
comment_count?: number;
};
const value = payload[field];
if (value === undefined) return;
uploads = uploads.map((u) => (u.id === payload.upload_id ? { ...u, [field]: value } : u));
if (selectedUpload?.id === payload.upload_id) {
selectedUpload = { ...selectedUpload, [field]: value };
}
} catch { /* ignore malformed payloads */ }
}
// Debounced page-1 fetch that *merges* (updates existing cards in place, prepends
// genuinely new ones) rather than replacing the array — preserves scroll and any
// pages already loaded below the fold.
function scheduleInPlaceRefresh() {
if (inPlaceRefreshTimer) return;
inPlaceRefreshTimer = setTimeout(() => {
inPlaceRefreshTimer = null;
void refreshFeedInPlace();
}, 800);
}
async function refreshFeedInPlace() {
try {
const params = new URLSearchParams();
if (selectedHashtag) params.set('hashtag', selectedHashtag);
params.set('limit', '20');
const res = await api.get<FeedResponse>(`/feed?${params}`);
const byId = new Map(res.uploads.map((u) => [u.id, u]));
const known = new Set(uploads.map((u) => u.id));
uploads = uploads.map((u) => byId.get(u.id) ?? u);
const fresh = res.uploads.filter((u) => !known.has(u.id));
if (fresh.length) uploads = [...fresh, ...uploads];
} catch {
// Background refresh — stay quiet, the next event or pull-to-refresh retries.
}
}
async function loadFeed(refresh = false) {
try {
const params = new URLSearchParams();
@@ -359,7 +413,7 @@
<!-- Live pull-progress indicator: grows during the drag, rotates past threshold,
swaps to a spinner once the network refresh kicks off. -->
{#if refreshing || pullProgress > 0}
<div class="pointer-events-none fixed left-0 right-0 top-2 z-40 flex justify-center">
<div class="pointer-events-none fixed left-0 right-0 top-[calc(env(safe-area-inset-top)+0.5rem)] z-40 flex justify-center">
<div
class="rounded-full bg-white/90 px-3 py-1 text-xs font-medium text-blue-600 shadow transition-opacity dark:bg-gray-900/90 dark:text-blue-300"
style="opacity: {refreshing ? 1 : Math.min(1, pullProgress)}"
@@ -386,7 +440,7 @@
</div>
{/if}
<!-- Sticky header — opaque fallback for browsers without backdrop-filter. -->
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 backdrop-blur supports-[not(backdrop-filter:blur(0))]:bg-white dark:border-gray-800 dark:bg-gray-900/95 dark:supports-[not(backdrop-filter:blur(0))]:bg-gray-900">
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 pt-[env(safe-area-inset-top)] backdrop-blur supports-[not(backdrop-filter:blur(0))]:bg-white dark:border-gray-800 dark:bg-gray-900/95 dark:supports-[not(backdrop-filter:blur(0))]:bg-gray-900">
<div class="mx-auto flex max-w-2xl items-center justify-between px-4 py-3">
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Galerie</h1>
@@ -538,35 +592,38 @@
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Tippe auf den Plus-Button unten!</p>
</div>
{:else if viewMode === 'list'}
<!-- List view: chronological full-width cards -->
<!-- List view: chronological full-width cards (DOM-windowed) -->
<div class="mx-auto max-w-2xl">
{#each uploads as upload (upload.id)}
<FeedListCard
{upload}
isOwn={upload.user_id === myUserId}
onlike={handleLike}
oncomment={openComments}
onselect={(u) => (selectedUpload = u)}
oncontextmenu={openContextSheet}
/>
{/each}
<VirtualFeed
mode="list"
uploads={uploads}
{myUserId}
onlike={handleLike}
oncomment={openComments}
onselect={(u) => (selectedUpload = u)}
oncontextmenu={openContextSheet}
/>
</div>
{:else}
<!-- Grid view: 3-col, filters applied -->
<!-- Grid view: 3-col, filters applied (DOM-windowed by row) -->
<div class="mx-auto max-w-2xl">
{#if displayUploads.length === 0}
<div class="py-16 text-center">
<p class="text-sm text-gray-400 dark:text-gray-500">Keine Treffer für die gewählten Filter.</p>
{#if nextCursor}
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">Es sind noch nicht alle Beiträge geladen — scrolle weiter, um mehr zu durchsuchen.</p>
{/if}
<button onclick={clearFilters} class="mt-2 text-sm text-blue-600 hover:underline dark:text-blue-400">Filter zurücksetzen</button>
</div>
{:else}
<FeedGrid
<VirtualFeed
mode="grid"
uploads={displayUploads}
{myUserId}
onlike={handleLike}
oncomment={openComments}
onselect={(u) => (selectedUpload = u)}
oncontextmenu={openContextSheet}
threeCol={true}
/>
{/if}
</div>