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:
@@ -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); // 0–1+ 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>
|
||||
|
||||
Reference in New Issue
Block a user