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:
295
frontend/src/lib/components/VirtualFeed.svelte
Normal file
295
frontend/src/lib/components/VirtualFeed.svelte
Normal file
@@ -0,0 +1,295 @@
|
||||
<script lang="ts">
|
||||
// DOM-windowing for the feed. Only the cards/rows inside (and a small overscan
|
||||
// around) the viewport are kept in the DOM — at ~1000 uploads this is the
|
||||
// difference between ~1000 heavy cards (each with its own image, HeartBurst,
|
||||
// long-press + double-tap listeners) and ~10-15.
|
||||
//
|
||||
// We use TanStack's *window* virtualizer (not an inner scroll container) on
|
||||
// purpose: the feed scrolls the document, and the page's sticky header,
|
||||
// pull-to-refresh, infinite-scroll sentinel and bottom nav all rely on that.
|
||||
// The window virtualizer measures against `window` scroll, so every one of
|
||||
// those keeps working untouched.
|
||||
//
|
||||
// Two layouts share one mechanism:
|
||||
// list — one full-width FeedListCard per row, heights *measured* (captions
|
||||
// make them variable). Keyed by upload id + `anchorTo:'start'` so an
|
||||
// SSE prepend (new upload) doesn't yank a scrolled-down reader.
|
||||
// grid — three square tiles per row, uniform height; still measured to shrug
|
||||
// off sub-pixel drift over hundreds of rows.
|
||||
import { createWindowVirtualizer } from '@tanstack/svelte-virtual';
|
||||
import { untrack } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { FeedUpload } from '$lib/types';
|
||||
import { dataMode } from '$lib/data-mode-store';
|
||||
import { longpress } from '$lib/actions/longpress';
|
||||
import FeedListCard from './FeedListCard.svelte';
|
||||
|
||||
interface Props {
|
||||
uploads: FeedUpload[];
|
||||
mode: 'list' | 'grid';
|
||||
myUserId: string | null;
|
||||
onlike: (id: string) => void;
|
||||
oncomment: (id: string) => void;
|
||||
onselect: (upload: FeedUpload) => void;
|
||||
oncontextmenu?: (upload: FeedUpload) => void;
|
||||
}
|
||||
|
||||
let { uploads, mode, myUserId, onlike, oncomment, onselect, oncontextmenu }: Props =
|
||||
$props();
|
||||
|
||||
const COLS = 3;
|
||||
const GRID_GAP = 2; // px — matches the `gap-0.5` the non-virtual grid used.
|
||||
const LIST_ESTIMATE = 700; // px — first-paint guess; real heights replace it on measure.
|
||||
|
||||
let listEl = $state<HTMLDivElement>();
|
||||
let containerWidth = $state(0);
|
||||
|
||||
// Distance from the top of the document to the list container, i.e. the height
|
||||
// of everything above it (sticky header + chips/search). Items' `start` values
|
||||
// are document-absolute (they include this margin), so we feed it back as
|
||||
// `scrollMargin` and subtract it again when positioning. Read live from layout
|
||||
// (getBoundingClientRect + scrollY is scroll-independent) so it self-corrects
|
||||
// when the header grows — e.g. grid filter chips appear.
|
||||
let scrollMargin = $state(0);
|
||||
|
||||
const rowCount = $derived(mode === 'grid' ? Math.ceil(uploads.length / COLS) : uploads.length);
|
||||
const colWidth = $derived(
|
||||
containerWidth > 0 ? (containerWidth - (COLS - 1) * GRID_GAP) / COLS : 120
|
||||
);
|
||||
|
||||
function isVideo(mime: string): boolean {
|
||||
return mime.startsWith('video/');
|
||||
}
|
||||
|
||||
// Grid tiles always use the small thumbnail — full media is one tap away in the
|
||||
// lightbox where the data-mode picker decides for real.
|
||||
function tileUrl(upload: FeedUpload): string {
|
||||
if (upload.thumbnail_url) return upload.thumbnail_url;
|
||||
if (upload.preview_url) return upload.preview_url;
|
||||
return $dataMode === 'original' ? `/api/v1/upload/${upload.id}/original` : '';
|
||||
}
|
||||
|
||||
// STABLE option callbacks — created once, never swapped. They read the live
|
||||
// reactive values (`colWidth`, `uploads`, `mode`) at *call* time, so they stay
|
||||
// current without needing a new function reference. This matters: `getItemKey`
|
||||
// is a dependency of virtual-core's measurements memo, so handing it a fresh
|
||||
// closure on every render would force an O(n) recompute. List keys by upload id
|
||||
// (so an SSE prepend keeps measured heights attached to the right card); grid
|
||||
// keys by row index (uniform rows, nothing to preserve).
|
||||
const estimateSize = (_i: number) => (mode === 'grid' ? colWidth : LIST_ESTIMATE);
|
||||
const getItemKey = (i: number) => (mode === 'list' ? (uploads[i]?.id ?? i) : i);
|
||||
|
||||
// `mode` is fixed for the lifetime of an instance (list and grid are rendered as
|
||||
// separate <VirtualFeed> elements in the parent's {#if} branches, so toggling
|
||||
// remounts rather than mutating this prop). Snapshot it without a reactive read
|
||||
// to set the layout-constant options once.
|
||||
const isGrid = untrack(() => mode === 'grid');
|
||||
|
||||
// Created with static placeholder count/margin; `applyOptions` pushes those.
|
||||
const virtualizer = createWindowVirtualizer<HTMLDivElement>({
|
||||
count: 0,
|
||||
estimateSize,
|
||||
getItemKey,
|
||||
overscan: isGrid ? 4 : 3,
|
||||
gap: isGrid ? GRID_GAP : 0,
|
||||
anchorTo: 'start',
|
||||
scrollMargin: 0
|
||||
});
|
||||
|
||||
// Only `count` (load-more / prepend / delete) and `scrollMargin` (header height
|
||||
// shifts) actually need to be pushed into the virtualizer. Like/comment SSE
|
||||
// patches reassign `uploads` without changing its length — those must NOT
|
||||
// trigger a setOptions (and its getBoundingClientRect reflow + store churn); the
|
||||
// affected card re-renders through normal reactivity instead. We read the raw
|
||||
// instance via `get()` rather than `$virtualizer` so writing never re-triggers
|
||||
// this effect (that would loop).
|
||||
let appliedCount = -1;
|
||||
let appliedMargin = Number.NaN;
|
||||
|
||||
function applyOptions() {
|
||||
const count = rowCount;
|
||||
const margin = listEl ? listEl.getBoundingClientRect().top + window.scrollY : 0;
|
||||
scrollMargin = margin; // drives the template transforms
|
||||
if (count === appliedCount && Math.abs(margin - appliedMargin) < 0.5) return;
|
||||
appliedCount = count;
|
||||
appliedMargin = margin;
|
||||
get(virtualizer).setOptions({ count, scrollMargin: margin });
|
||||
}
|
||||
|
||||
// Re-apply when the row count or column width changes (load-more, prepend,
|
||||
// delete, filter, rotate). `colWidth`/`rowCount` are the only tracked reads, so a
|
||||
// length-stable like/comment patch doesn't re-run this.
|
||||
$effect(() => {
|
||||
void rowCount;
|
||||
void colWidth;
|
||||
applyOptions();
|
||||
});
|
||||
|
||||
// Header offset can also shift on resize without a count change (orientation,
|
||||
// on-screen keyboard, font scaling).
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const onResize = () => applyOptions();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
// Hand each rendered row to the virtualizer's ResizeObserver (it reads the row's
|
||||
// `data-index` and caches the real height by item key). On `destroy` we call
|
||||
// `measureElement(null)`, which sweeps now-disconnected nodes out of the
|
||||
// internal cache + ResizeObserver — without it, every card that scrolls out of
|
||||
// the window stays observed and retained, defeating the point of virtualizing.
|
||||
function measure(node: HTMLDivElement) {
|
||||
get(virtualizer).measureElement(node);
|
||||
return {
|
||||
update() {
|
||||
get(virtualizer).measureElement(node);
|
||||
},
|
||||
destroy() {
|
||||
get(virtualizer).measureElement(null);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={listEl} bind:clientWidth={containerWidth} class="w-full">
|
||||
{#if browser}
|
||||
<div style="position: relative; width: 100%; height: {$virtualizer.getTotalSize()}px;">
|
||||
{#each $virtualizer.getVirtualItems() as item (item.key)}
|
||||
{#if mode === 'list'}
|
||||
{@const upload = uploads[item.index]}
|
||||
<div
|
||||
data-index={item.index}
|
||||
use:measure
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({item.start -
|
||||
scrollMargin}px);"
|
||||
>
|
||||
{#if upload}
|
||||
<FeedListCard
|
||||
{upload}
|
||||
isOwn={upload.user_id === myUserId}
|
||||
{onlike}
|
||||
{oncomment}
|
||||
{onselect}
|
||||
{oncontextmenu}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
data-index={item.index}
|
||||
use:measure
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({item.start -
|
||||
scrollMargin}px);"
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-0.5">
|
||||
{#each uploads.slice(item.index * COLS, item.index * COLS + COLS) as upload (upload.id)}
|
||||
<!-- Tile — mirrors the markup the old FeedGrid used. -->
|
||||
<div
|
||||
class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-800"
|
||||
use:longpress={{ duration: 500 }}
|
||||
onlongpress={() => oncontextmenu?.(upload)}
|
||||
>
|
||||
<button
|
||||
onclick={() => onselect(upload)}
|
||||
class="block h-full w-full"
|
||||
aria-label="Upload anzeigen"
|
||||
>
|
||||
{#if isVideo(upload.mime_type)}
|
||||
<div class="flex h-full items-center justify-center bg-gray-800">
|
||||
{#if tileUrl(upload)}
|
||||
<img
|
||||
src={tileUrl(upload)}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{/if}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<svg class="h-10 w-10 text-white/80" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{:else if tileUrl(upload)}
|
||||
<img
|
||||
src={tileUrl(upload)}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center text-gray-400">
|
||||
<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-2"
|
||||
>
|
||||
<p class="truncate text-xs font-medium text-white">{upload.uploader_name}</p>
|
||||
<div class="mt-0.5 flex items-center gap-3 text-xs text-white/80">
|
||||
<button
|
||||
class="pointer-events-auto -m-1 flex items-center gap-0.5 p-1"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onlike(upload.id);
|
||||
}}
|
||||
aria-pressed={upload.liked_by_me}
|
||||
aria-label={upload.liked_by_me ? 'Gefällt mir nicht mehr' : 'Gefällt mir'}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 {upload.liked_by_me ? 'fill-red-400 text-red-400' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
{upload.like_count}
|
||||
</button>
|
||||
<button
|
||||
class="pointer-events-auto -m-1 flex items-center gap-0.5 p-1"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
oncomment(upload.id);
|
||||
}}
|
||||
aria-label="Kommentare anzeigen"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
{upload.comment_count}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user