frontend(plumbing): shared theme tokens, cross-cutting stores, gestures, sheets
The plumbing layer the v0.16 UI features (and dark mode) build on.
Shared design tokens (Tailwind v4):
- tailwind-theme.css (new): @custom-variant dark (class-driven, beats OS
default) + @theme color/font/radius tokens + baseline html/html.dark
rules so any page that hasn't been re-themed still renders the right
body bg + color-scheme.
- src/app.css + export-viewer/src/app.css now import the shared theme.
- src/app.html: 6-line FOUC guard sets <html class="dark"> before paint
(mirrored from theme-store.ts) so dark reloads no longer flash white.
Adds <meta name="theme-color"> kept in sync by initTheme().
Cross-cutting stores (one per concern, per docs/FEATURES §2.9):
- data-mode-store.ts: 'saver' | 'original' per-device, plus pickMediaUrl
helper so feed cards / lightbox / diashow all resolve URLs the same way.
- privacy-note-store.ts: hydrated from /me/context, refreshed on SSE
event-updated.
- quota-store.ts: { enabled, used, limit, active_uploaders, free_disk },
refreshed after each upload completes.
- theme-store.ts: 'system' | 'light' | 'dark' preference + derived
appliedTheme + initTheme() that syncs <html class>, localStorage,
and the theme-color meta. Listens to prefers-color-scheme.
- auth.ts: currentPin writable mirror + clearPin() helper called from
the global pin-reset SSE handler — fixes the stale-PIN bug where the
localStorage copy survived a reset.
DTO mirror:
- types.ts: QuotaDto, MeContextDto, PinResetResponse, DeltaResponse each
carry a `// mirrors backend/...` comment per the lib README convention.
SSE client:
- sse.ts: KNOWN_EVENTS registry (one entry per server-emitted type),
synthetic feed-delta dispatched after foreground reconnect via the
/feed/delta?since= endpoint, exponential backoff (1 → 60 s + jitter)
on errors, attempt counter reset on user-initiated visibility resume.
Upload queue:
- upload-queue.ts: IDB schema bumped to v2 — entries tagged with userId;
loadQueue filters by current user (no cross-user leak on shared
devices); uploadItem refuses to upload an entry whose userId differs
from getUserId() (defense-in-depth); new clearQueue() called on
explicit logout. v2 upgrade wipes pre-v2 entries (no userId, can't
attribute safely).
Mobile primitives:
- actions/longpress.ts: 500 ms hold with 10 px move tolerance, swallows
the next click + the right-click contextmenu so the gesture doesn't
double-fire the inner button's onclick.
- actions/doubletap.ts: tap-pair detector that preventDefaults the
second tap so iOS Safari doesn't also zoom on double-tap.
- components/ContextSheet.svelte: generic bottom sheet driven by a
ContextAction[] prop. Reused by feed posts, comments, host user rows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
55
frontend/src/lib/actions/doubletap.ts
Normal file
55
frontend/src/lib/actions/doubletap.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// 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.
|
||||
//
|
||||
// Native `dblclick` exists, but on iOS Safari it also zooms the page; gating on
|
||||
// pointer events lets us preventDefault selectively and avoid the zoom.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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) {
|
||||
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;
|
||||
};
|
||||
|
||||
node.addEventListener('pointerup', onPointerUp);
|
||||
|
||||
return {
|
||||
update(newOptions) {
|
||||
interval = newOptions.interval ?? INTERVAL_MS;
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener('pointerup', onPointerUp);
|
||||
}
|
||||
};
|
||||
}
|
||||
111
frontend/src/lib/actions/longpress.ts
Normal file
111
frontend/src/lib/actions/longpress.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user