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:
@@ -1,9 +1,14 @@
|
||||
// Svelte action — fires a `doubletap` CustomEvent when two pointerup events occur
|
||||
// within `interval` ms on roughly the same spot. Used in the lightbox for the
|
||||
// Instagram-style "double-tap to like" gesture.
|
||||
// Svelte action — the single source of truth for tap gestures on a media element.
|
||||
// Fires a `doubletap` CustomEvent when two pointerup events occur within `interval`
|
||||
// ms on roughly the same spot (Instagram-style "double-tap to like"), and a
|
||||
// `singletap` CustomEvent once `interval` has elapsed with no second tap. Owning
|
||||
// both gestures in one place means a component no longer juggles its own debounce
|
||||
// timer alongside a separate onclick handler.
|
||||
//
|
||||
// Native `dblclick` exists, but on iOS Safari it also zooms the page; gating on
|
||||
// pointer events lets us preventDefault selectively and avoid the zoom.
|
||||
// pointer events lets us preventDefault selectively and avoid the zoom. Keyboard
|
||||
// activation still arrives as a normal `click` (detail === 0) — handle that on the
|
||||
// element itself for an immediate, latency-free open.
|
||||
|
||||
import type { ActionReturn } from 'svelte/action';
|
||||
|
||||
@@ -16,6 +21,7 @@ export interface DoubletapOptions {
|
||||
|
||||
interface DoubletapAttributes {
|
||||
'ondoubletap'?: (event: CustomEvent<void>) => void;
|
||||
'onsingletap'?: (event: CustomEvent<void>) => void;
|
||||
}
|
||||
|
||||
export function doubletap(
|
||||
@@ -26,12 +32,21 @@ export function doubletap(
|
||||
let lastTime = 0;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
let singleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearSingle = () => {
|
||||
if (singleTimer) {
|
||||
clearTimeout(singleTimer);
|
||||
singleTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
const now = performance.now();
|
||||
const dx = Math.abs(e.clientX - lastX);
|
||||
const dy = Math.abs(e.clientY - lastY);
|
||||
if (now - lastTime < interval && dx < MOVE_THRESHOLD && dy < MOVE_THRESHOLD) {
|
||||
clearSingle(); // the pending single-tap was actually the first of a double
|
||||
e.preventDefault();
|
||||
node.dispatchEvent(new CustomEvent('doubletap'));
|
||||
lastTime = 0; // reset so a triple-tap doesn't re-fire
|
||||
@@ -40,6 +55,12 @@ export function doubletap(
|
||||
lastTime = now;
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
// Defer the single-tap action until we're sure no second tap follows.
|
||||
clearSingle();
|
||||
singleTimer = setTimeout(() => {
|
||||
singleTimer = null;
|
||||
node.dispatchEvent(new CustomEvent('singletap'));
|
||||
}, interval);
|
||||
};
|
||||
|
||||
node.addEventListener('pointerup', onPointerUp);
|
||||
@@ -49,6 +70,7 @@ export function doubletap(
|
||||
interval = newOptions.interval ?? INTERVAL_MS;
|
||||
},
|
||||
destroy() {
|
||||
clearSingle();
|
||||
node.removeEventListener('pointerup', onPointerUp);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user