The frontend had no JS/TS linter — only svelte-check. Add flat-config ESLint (typescript-eslint
+ eslint-plugin-svelte) and Prettier (tabs/single-quote, matching the existing style).
Rules encode "catch bugs, not enforce taste":
- svelte/require-each-key KEPT — it is the exact bug class as the feed mis-tap fix. Fixed every
flagged block: keyed activeFilters, filteredUsers, stagedFiles (by previewUrl), captionTags,
admin tabs/jobs/users, the export-viewer suggestions/filters/comments, and the static skeleton
loops.
- svelte/prefer-svelte-reactivity KEPT — inline-disabled only the verified-safe sites (a local
freq Map in a $derived.by, throwaway URLSearchParams query builders), with a reason each.
- svelte/no-navigation-without-resolve OFF — wants resolve() around every goto()/href; taste, not
a bug, and pure churn.
- svelte/no-unused-svelte-ignore OFF — those comments are consumed by svelte-check, which ESLint
can't see, so it wrongly calls them unused; removing them would reintroduce a11y warnings.
- no-explicit-any OFF for *.test.ts only (partial fixtures legitimately use any).
Real code fixes beyond keys: removed a dead jobLabel(), an unused ViewerComment import and unused
catch binding, an unused scroll-lock arg, and replaced an empty interface with a type alias.
Then `prettier --write` (54 files). Formatting only. Verified: eslint clean, svelte-check 0 errors,
vitest 46 passed, vite build succeeds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
112 lines
3.4 KiB
TypeScript
112 lines
3.4 KiB
TypeScript
// Svelte action — fires a `longpress` CustomEvent when the user holds the pointer
|
|
// down for `duration` ms without moving too far.
|
|
//
|
|
// Usage:
|
|
// <div use:longpress={{ duration: 500 }} onlongpress={() => openMenu()} />
|
|
//
|
|
// Cancels on:
|
|
// - pointerup before duration elapses
|
|
// - pointermove > MOVE_THRESHOLD pixels (so a scroll attempt doesn't accidentally
|
|
// trigger the menu)
|
|
// - pointercancel / pointerleave / contextmenu
|
|
//
|
|
// When the long-press fires we also swallow the *next* click that comes from the same
|
|
// pointer-release. Without this, holding on a post card would (a) open the context
|
|
// sheet and then (b) trigger the post's onclick → lightbox at pointer-up. Same goes
|
|
// for the native contextmenu event on long-press desktop right-click.
|
|
|
|
import type { ActionReturn } from 'svelte/action';
|
|
|
|
const MOVE_THRESHOLD = 10; // px
|
|
|
|
export interface LongpressOptions {
|
|
duration?: number;
|
|
}
|
|
|
|
interface LongpressAttributes {
|
|
onlongpress?: (event: CustomEvent<void>) => void;
|
|
}
|
|
|
|
export function longpress(
|
|
node: HTMLElement,
|
|
options: LongpressOptions = {}
|
|
): ActionReturn<LongpressOptions, LongpressAttributes> {
|
|
let duration = options.duration ?? 500;
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
/** Set true when the long-press fires; the very next click is then swallowed. */
|
|
let suppressNextClick = false;
|
|
|
|
const cancel = () => {
|
|
if (timer !== null) {
|
|
clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
};
|
|
|
|
const fireLongpress = () => {
|
|
suppressNextClick = true;
|
|
// Reset the flag on the next event loop if no click followed — protects against
|
|
// the case where the user lifts their finger by lifting the device, no click.
|
|
setTimeout(() => {
|
|
suppressNextClick = false;
|
|
}, 400);
|
|
node.dispatchEvent(new CustomEvent('longpress'));
|
|
timer = null;
|
|
};
|
|
|
|
const onPointerDown = (e: PointerEvent) => {
|
|
startX = e.clientX;
|
|
startY = e.clientY;
|
|
suppressNextClick = false;
|
|
cancel();
|
|
timer = setTimeout(fireLongpress, duration);
|
|
};
|
|
|
|
const onPointerMove = (e: PointerEvent) => {
|
|
if (timer === null) return;
|
|
const dx = Math.abs(e.clientX - startX);
|
|
const dy = Math.abs(e.clientY - startY);
|
|
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) cancel();
|
|
};
|
|
|
|
const onClickCapture = (e: Event) => {
|
|
if (suppressNextClick) {
|
|
suppressNextClick = false;
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
|
|
const onContextMenu = (e: Event) => {
|
|
// Block the desktop right-click menu when we've already fired our own.
|
|
if (suppressNextClick) e.preventDefault();
|
|
};
|
|
|
|
node.addEventListener('pointerdown', onPointerDown);
|
|
node.addEventListener('pointermove', onPointerMove);
|
|
node.addEventListener('pointerup', cancel);
|
|
node.addEventListener('pointerleave', cancel);
|
|
node.addEventListener('pointercancel', cancel);
|
|
node.addEventListener('contextmenu', onContextMenu);
|
|
// Capture phase so we beat the inner button's bubbling click handler.
|
|
node.addEventListener('click', onClickCapture, true);
|
|
|
|
return {
|
|
update(newOptions) {
|
|
duration = newOptions.duration ?? 500;
|
|
},
|
|
destroy() {
|
|
cancel();
|
|
node.removeEventListener('pointerdown', onPointerDown);
|
|
node.removeEventListener('pointermove', onPointerMove);
|
|
node.removeEventListener('pointerup', cancel);
|
|
node.removeEventListener('pointerleave', cancel);
|
|
node.removeEventListener('pointercancel', cancel);
|
|
node.removeEventListener('contextmenu', onContextMenu);
|
|
node.removeEventListener('click', onClickCapture, true);
|
|
}
|
|
};
|
|
}
|