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>
78 lines
2.4 KiB
TypeScript
78 lines
2.4 KiB
TypeScript
// 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. 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';
|
|
|
|
const INTERVAL_MS = 300;
|
|
const MOVE_THRESHOLD = 12;
|
|
|
|
export interface DoubletapOptions {
|
|
interval?: number;
|
|
}
|
|
|
|
interface DoubletapAttributes {
|
|
'ondoubletap'?: (event: CustomEvent<void>) => void;
|
|
'onsingletap'?: (event: CustomEvent<void>) => void;
|
|
}
|
|
|
|
export function doubletap(
|
|
node: HTMLElement,
|
|
options: DoubletapOptions = {}
|
|
): ActionReturn<DoubletapOptions, DoubletapAttributes> {
|
|
let interval = options.interval ?? INTERVAL_MS;
|
|
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
|
|
return;
|
|
}
|
|
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);
|
|
|
|
return {
|
|
update(newOptions) {
|
|
interval = newOptions.interval ?? INTERVAL_MS;
|
|
},
|
|
destroy() {
|
|
clearSingle();
|
|
node.removeEventListener('pointerup', onPointerUp);
|
|
}
|
|
};
|
|
}
|