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:
@@ -1 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
/* Pulls the live app's design tokens so the offline keepsake matches visually.
|
||||
* See ../../src/tailwind-theme.css for the source of truth. */
|
||||
@import "../../src/tailwind-theme.css";
|
||||
|
||||
@@ -1 +1 @@
|
||||
@import "tailwindcss";
|
||||
@import "./tailwind-theme.css";
|
||||
|
||||
@@ -4,6 +4,22 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<!--
|
||||
FOUC guard: apply the dark class *before* paint, so reloads of pages with
|
||||
theme=dark don't flash a white screen. Mirrors the logic in
|
||||
`src/lib/theme-store.ts`; kept in sync by hand (it's 6 lines).
|
||||
-->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var pref = localStorage.getItem('eventsnap_theme') || 'system';
|
||||
var dark = pref === 'dark' ||
|
||||
(pref === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
if (dark) document.documentElement.classList.add('dark');
|
||||
} catch (_) {}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,13 @@ const DISPLAY_NAME_KEY = 'eventsnap_display_name';
|
||||
|
||||
export const isAuthenticated = writable(false);
|
||||
|
||||
/**
|
||||
* Reactive mirror of `localStorage[PIN_KEY]`. Subscribers (the My Account page) see
|
||||
* the PIN change immediately when an SSE `pin-reset` event invalidates it from any
|
||||
* route — keeps the displayed PIN consistent with the server hash.
|
||||
*/
|
||||
export const currentPin = writable<string | null>(null);
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
@@ -18,6 +25,17 @@ export function getPin(): string | null {
|
||||
return localStorage.getItem(PIN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the locally-cached recovery PIN. Called when the server resets it (host
|
||||
* action) — the cached plaintext no longer matches the bcrypt hash, so showing it
|
||||
* would mislead the user.
|
||||
*/
|
||||
export function clearPin(): void {
|
||||
if (!browser) return;
|
||||
localStorage.removeItem(PIN_KEY);
|
||||
currentPin.set(null);
|
||||
}
|
||||
|
||||
export function getUserId(): string | null {
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem(USER_ID_KEY);
|
||||
@@ -42,7 +60,10 @@ export function getExpiry(): Date | null {
|
||||
export function setAuth(jwt: string, pin: string | null, userId: string, displayName?: string): void {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(TOKEN_KEY, jwt);
|
||||
if (pin) localStorage.setItem(PIN_KEY, pin);
|
||||
if (pin) {
|
||||
localStorage.setItem(PIN_KEY, pin);
|
||||
currentPin.set(pin);
|
||||
}
|
||||
localStorage.setItem(USER_ID_KEY, userId);
|
||||
if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName);
|
||||
isAuthenticated.set(true);
|
||||
@@ -70,4 +91,5 @@ export function getRole(): 'guest' | 'host' | 'admin' | null {
|
||||
export function initAuth(): void {
|
||||
if (!browser) return;
|
||||
isAuthenticated.set(!!getToken());
|
||||
currentPin.set(getPin());
|
||||
}
|
||||
|
||||
95
frontend/src/lib/components/ContextSheet.svelte
Normal file
95
frontend/src/lib/components/ContextSheet.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<script lang="ts" module>
|
||||
// Action shape used by every long-press / kebab menu in the app. Defined in `module`
|
||||
// so importers don't pay for instance state.
|
||||
export interface ContextAction {
|
||||
label: string;
|
||||
icon?: string; // optional emoji or unicode glyph
|
||||
tone?: 'default' | 'danger';
|
||||
disabled?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// Reusable bottom-sheet for context menus. Consumers pass `open`, an array of
|
||||
// `actions`, and a close callback. Mobile: long-press → open. Desktop: kebab
|
||||
// icon → open. The sheet itself is platform-agnostic.
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
actions: ContextAction[];
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
let { open, actions, onClose, title }: Props = $props();
|
||||
|
||||
async function handle(action: ContextAction) {
|
||||
if (action.disabled) return;
|
||||
try {
|
||||
await action.onClick();
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/50 transition-opacity duration-200"
|
||||
class:opacity-0={!open}
|
||||
class:pointer-events-none={!open}
|
||||
class:opacity-100={open}
|
||||
onclick={onClose}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<!-- Sheet -->
|
||||
<div
|
||||
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white transition-transform duration-200 dark:bg-gray-900"
|
||||
class:translate-y-full={!open}
|
||||
class:translate-y-0={open}
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="flex justify-center pt-3 pb-1">
|
||||
<div class="h-1 w-10 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
|
||||
{#if title}
|
||||
<p class="px-5 pt-1 pb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{title}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1 px-3 pb-3 pt-1">
|
||||
{#each actions as action (action.label)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={action.disabled}
|
||||
onclick={() => handle(action)}
|
||||
class="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left text-base transition active:bg-gray-100 disabled:opacity-50 dark:active:bg-gray-700"
|
||||
class:text-gray-900={action.tone !== 'danger'}
|
||||
class:dark:text-gray-100={action.tone !== 'danger'}
|
||||
class:text-red-600={action.tone === 'danger'}
|
||||
class:dark:text-red-400={action.tone === 'danger'}
|
||||
class:hover:bg-gray-50={!action.disabled}
|
||||
class:dark:hover:bg-gray-800={!action.disabled}
|
||||
>
|
||||
{#if action.icon}
|
||||
<span class="text-xl leading-none">{action.icon}</span>
|
||||
{/if}
|
||||
<span class="font-medium">{action.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="mt-2 w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
56
frontend/src/lib/data-mode-store.ts
Normal file
56
frontend/src/lib/data-mode-store.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Per-device "Datenmodus" — Saver loads compressed previews (default), Original loads
|
||||
// the full file via the auth-gated `/api/v1/upload/{id}/original` endpoint.
|
||||
//
|
||||
// Stored per-device in localStorage (not per-user) because data plans are a property
|
||||
// of the device the guest is currently holding, not their identity.
|
||||
//
|
||||
// Used by:
|
||||
// - Feed cards (FeedListCard / FeedGrid) to pick which URL to render
|
||||
// - Lightbox
|
||||
// - Diashow
|
||||
// See [docs/FEATURES.md §2.5] for the user-facing model.
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type DataMode = 'saver' | 'original';
|
||||
|
||||
const KEY = 'eventsnap_data_mode';
|
||||
const DEFAULT: DataMode = 'saver';
|
||||
|
||||
function readInitial(): DataMode {
|
||||
if (!browser) return DEFAULT;
|
||||
const raw = localStorage.getItem(KEY);
|
||||
return raw === 'original' || raw === 'saver' ? raw : DEFAULT;
|
||||
}
|
||||
|
||||
export const dataMode = writable<DataMode>(readInitial());
|
||||
|
||||
if (browser) {
|
||||
dataMode.subscribe((value) => {
|
||||
try {
|
||||
localStorage.setItem(KEY, value);
|
||||
} catch {
|
||||
// localStorage may be unavailable (Safari private mode); ignore.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL for a feed upload given the current data mode and the URL variants
|
||||
* the backend returned. Centralised so every consumer (cards, lightbox, diashow)
|
||||
* follows the same fallback rule:
|
||||
* Original mode → original API route. Falls back to preview if no upload id is
|
||||
* available (defensive — shouldn't happen in practice).
|
||||
* Saver mode → preview URL (compressed), falling back to thumbnail and then
|
||||
* original.
|
||||
*/
|
||||
export function pickMediaUrl(
|
||||
mode: DataMode,
|
||||
upload: { id: string; preview_url: string | null; thumbnail_url: string | null }
|
||||
): string {
|
||||
if (mode === 'original') {
|
||||
return `/api/v1/upload/${upload.id}/original`;
|
||||
}
|
||||
return upload.preview_url ?? upload.thumbnail_url ?? `/api/v1/upload/${upload.id}/original`;
|
||||
}
|
||||
10
frontend/src/lib/privacy-note-store.ts
Normal file
10
frontend/src/lib/privacy-note-store.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Holds the admin-configured Datenschutzhinweis as raw text. Populated by
|
||||
// `GET /api/v1/me/context` on app start and updated live via the SSE `event-updated`
|
||||
// signal so admin edits propagate without a reload.
|
||||
//
|
||||
// Empty string ('') means "not configured" — the My Account page hides the section
|
||||
// entirely in that case.
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const privacyNote = writable<string>('');
|
||||
38
frontend/src/lib/quota-store.ts
Normal file
38
frontend/src/lib/quota-store.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Live snapshot of the per-user storage quota. Refreshed on app start, after every
|
||||
// upload completes, and whenever the account page mounts. Mirrors backend
|
||||
// `handlers::me::QuotaDto`.
|
||||
//
|
||||
// `enabled = false` means quota enforcement is currently off in admin config; the UI
|
||||
// hides the widget entirely in that case.
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
import { api } from './api';
|
||||
|
||||
export interface QuotaSnapshot {
|
||||
enabled: boolean;
|
||||
used_bytes: number;
|
||||
limit_bytes: number | null;
|
||||
active_uploaders: number;
|
||||
free_disk_bytes: number;
|
||||
}
|
||||
|
||||
const empty: QuotaSnapshot = {
|
||||
enabled: false,
|
||||
used_bytes: 0,
|
||||
limit_bytes: null,
|
||||
active_uploaders: 0,
|
||||
free_disk_bytes: 0
|
||||
};
|
||||
|
||||
export const quotaStore = writable<QuotaSnapshot>(empty);
|
||||
|
||||
/** Refresh from the server. Swallows errors so a transient network blip doesn't
|
||||
* break the account page; the previous snapshot just stays in place. */
|
||||
export async function refreshQuota(): Promise<void> {
|
||||
try {
|
||||
const snap = await api.get<QuotaSnapshot>('/me/quota');
|
||||
quotaStore.set(snap);
|
||||
} catch {
|
||||
// keep previous snapshot
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,19 @@
|
||||
// Thin EventSource wrapper with a per-event-type registration pattern.
|
||||
//
|
||||
// Subscribers register via `onSseEvent(type, handler)` and receive the raw payload
|
||||
// string. The list of event types we know how to relay lives in `KNOWN_EVENTS` so
|
||||
// adding one new is one constant entry — keeps the file friendly to extension.
|
||||
//
|
||||
// Lifecycle:
|
||||
// - Connection survives backgrounding via the visibility listener at the bottom of
|
||||
// this file (closes on hidden, reopens on visible).
|
||||
// - On reopen we fire a `feed-delta` synthetic event with the gap since last seen
|
||||
// to whoever subscribes. The feed page is the typical consumer; it merges the
|
||||
// delta into its in-memory list.
|
||||
|
||||
import { getToken } from './auth';
|
||||
import { api } from './api';
|
||||
import type { DeltaResponse } from './types';
|
||||
|
||||
type EventHandler = (data: string) => void;
|
||||
|
||||
@@ -6,13 +21,41 @@ let eventSource: EventSource | null = null;
|
||||
let lastEventTime: string | null = null;
|
||||
const handlers: Map<string, EventHandler[]> = new Map();
|
||||
|
||||
/** Consecutive reconnect attempts since last successful onopen. Reset on success. */
|
||||
let reconnectAttempt = 0;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* SSE event names emitted by the backend. Add new ones here as `state.sse_tx.send`
|
||||
* call sites grow — every entry becomes a relay registration below.
|
||||
*/
|
||||
const KNOWN_EVENTS = [
|
||||
'new-upload',
|
||||
'upload-processed',
|
||||
'upload-error',
|
||||
'upload-deleted',
|
||||
'like-update',
|
||||
'new-comment',
|
||||
'event-closed',
|
||||
'event-opened',
|
||||
'event-updated',
|
||||
'export-progress',
|
||||
'export-available',
|
||||
'pin-reset'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Synthetic event types — not emitted by the server, dispatched locally to fan out
|
||||
* cross-cutting state changes (e.g. delta-fetch results after a reconnect).
|
||||
*/
|
||||
export type SyntheticEvent = 'feed-delta';
|
||||
|
||||
export function onSseEvent(eventType: string, handler: EventHandler): () => void {
|
||||
if (!handlers.has(eventType)) {
|
||||
handlers.set(eventType, []);
|
||||
}
|
||||
handlers.get(eventType)!.push(handler);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const list = handlers.get(eventType);
|
||||
if (list) {
|
||||
@@ -26,26 +69,37 @@ export function connectSse(): void {
|
||||
const token = getToken();
|
||||
if (!token || eventSource) return;
|
||||
|
||||
// EventSource doesn't support custom headers, so pass token as query param
|
||||
// The backend will need to accept this — or we use a polyfill / fetch-based SSE
|
||||
// For simplicity, use native EventSource with token in URL
|
||||
// EventSource doesn't support custom headers, so pass token as query param.
|
||||
eventSource = new EventSource(`/api/v1/stream?token=${encodeURIComponent(token)}`);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
// Successful connection — reset the backoff counter.
|
||||
reconnectAttempt = 0;
|
||||
// If we have a previous timestamp this is a reconnect — fetch the gap.
|
||||
const since = lastEventTime;
|
||||
if (since) {
|
||||
void deltaFetchAndFan(since);
|
||||
}
|
||||
lastEventTime = new Date().toISOString();
|
||||
};
|
||||
|
||||
eventSource.addEventListener('new-upload', (e) => dispatch('new-upload', e.data));
|
||||
eventSource.addEventListener('upload-processed', (e) => dispatch('upload-processed', e.data));
|
||||
eventSource.addEventListener('like-update', (e) => dispatch('like-update', e.data));
|
||||
eventSource.addEventListener('new-comment', (e) => dispatch('new-comment', e.data));
|
||||
eventSource.addEventListener('export-available', (e) => dispatch('export-available', e.data));
|
||||
for (const eventName of KNOWN_EVENTS) {
|
||||
eventSource.addEventListener(eventName, (e) =>
|
||||
dispatch(eventName, (e as MessageEvent).data)
|
||||
);
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// EventSource auto-reconnects, but we track the time for delta-fetch
|
||||
// EventSource auto-reconnects but the connection state can stay broken; close
|
||||
// and try again ourselves with exponential backoff capped at 60s. Prevents
|
||||
// retry storms (and lets the backend recover quietly) when the server is down
|
||||
// for a while or when 100+ guests reconnect simultaneously after an outage.
|
||||
disconnectSse();
|
||||
// Reconnect after a short delay
|
||||
setTimeout(connectSse, 3000);
|
||||
reconnectAttempt++;
|
||||
const delay = Math.min(60_000, 1_000 * 2 ** (reconnectAttempt - 1));
|
||||
const jitter = Math.random() * 500;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connectSse, delay + jitter);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,6 +108,10 @@ export function disconnectSse(): void {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastEventTime(): string | null {
|
||||
@@ -74,12 +132,33 @@ function dispatch(eventType: string, data: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Page Visibility API integration
|
||||
/**
|
||||
* Fetch all feed activity since `since` and fan it out as a synthetic `feed-delta`
|
||||
* event. Subscribers (typically the feed page) merge the result into their
|
||||
* in-memory list. Swallows errors — a failed delta is non-fatal; the next live
|
||||
* SSE event will keep the feed moving.
|
||||
*/
|
||||
async function deltaFetchAndFan(since: string): Promise<void> {
|
||||
try {
|
||||
const response = await api.get<DeltaResponse>(
|
||||
`/feed/delta?since=${encodeURIComponent(since)}`
|
||||
);
|
||||
dispatch('feed-delta', JSON.stringify(response));
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// Page Visibility API: close while hidden, reopen on focus. On reopen `connectSse`'s
|
||||
// `onopen` runs the delta fetch.
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
disconnectSse();
|
||||
} else {
|
||||
// User-initiated reconnect — clear backoff so we don't wait out a long
|
||||
// retry delay that was scheduled from a prior background error.
|
||||
reconnectAttempt = 0;
|
||||
connectSse();
|
||||
}
|
||||
});
|
||||
|
||||
71
frontend/src/lib/theme-store.ts
Normal file
71
frontend/src/lib/theme-store.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Per-device theme preference. Mirrors the data-mode store pattern.
|
||||
//
|
||||
// Three options:
|
||||
// - 'system' follows `prefers-color-scheme` (default for new visitors)
|
||||
// - 'light' force light
|
||||
// - 'dark' force dark
|
||||
//
|
||||
// `appliedTheme` is the *resolved* light/dark for the current moment — derived from
|
||||
// `themePreference` + the OS preference when 'system' is selected. Consumers that
|
||||
// just want "is it dark right now?" should read `$appliedTheme`.
|
||||
//
|
||||
// The `applyTheme` side-effect toggles a `dark` class on the <html> element so the
|
||||
// Tailwind v4 dark variant (configured in `tailwind-theme.css`) kicks in for every
|
||||
// `dark:` utility across the app.
|
||||
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type ThemePreference = 'system' | 'light' | 'dark';
|
||||
export type AppliedTheme = 'light' | 'dark';
|
||||
|
||||
const KEY = 'eventsnap_theme';
|
||||
const DEFAULT: ThemePreference = 'system';
|
||||
|
||||
function readInitial(): ThemePreference {
|
||||
if (!browser) return DEFAULT;
|
||||
const raw = localStorage.getItem(KEY);
|
||||
return raw === 'light' || raw === 'dark' || raw === 'system' ? raw : DEFAULT;
|
||||
}
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
if (!browser) return false;
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
}
|
||||
|
||||
export const themePreference = writable<ThemePreference>(readInitial());
|
||||
|
||||
/** Resolved light/dark — recomputed when the preference or OS theme changes. */
|
||||
export const appliedTheme = derived(themePreference, ($pref, set) => {
|
||||
const compute = () => set($pref === 'system' ? (systemPrefersDark() ? 'dark' : 'light') : $pref);
|
||||
compute();
|
||||
if (!browser || $pref !== 'system') return; // no OS listener needed when forced
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const listener = () => compute();
|
||||
mq.addEventListener('change', listener);
|
||||
return () => mq.removeEventListener('change', listener);
|
||||
});
|
||||
|
||||
/** Side-effect: keep the <html> class + localStorage + meta-color in sync. */
|
||||
export function initTheme(): void {
|
||||
if (!browser) return;
|
||||
themePreference.subscribe((pref) => {
|
||||
try {
|
||||
localStorage.setItem(KEY, pref);
|
||||
} catch {
|
||||
// localStorage may be unavailable (Safari private mode); ignore.
|
||||
}
|
||||
});
|
||||
appliedTheme.subscribe((mode) => {
|
||||
document.documentElement.classList.toggle('dark', mode === 'dark');
|
||||
// Update the browser chrome / status bar color so iOS Safari + Android stop
|
||||
// painting it white on a dark page.
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', mode === 'dark' ? '#111827' : '#ffffff');
|
||||
});
|
||||
}
|
||||
|
||||
/** Convenience for one-off reads outside reactive contexts. */
|
||||
export function isDark(): boolean {
|
||||
return get(appliedTheme) === 'dark';
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
// Type mirrors of the Rust DTOs. Keep these in sync by hand — comments call out the
|
||||
// originating source file so future-you can grep both sides. See
|
||||
// `frontend/src/lib/README.md` for the convention.
|
||||
|
||||
// mirrors backend/src/handlers/feed.rs::FeedUpload
|
||||
export interface FeedUpload {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -12,12 +17,46 @@ export interface FeedUpload {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// mirrors backend/src/handlers/feed.rs::FeedResponse
|
||||
export interface FeedResponse {
|
||||
uploads: FeedUpload[];
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
// mirrors backend/src/handlers/feed.rs::DeltaResponse
|
||||
export interface DeltaResponse {
|
||||
uploads: FeedUpload[];
|
||||
deleted_ids: string[];
|
||||
}
|
||||
|
||||
// mirrors backend/src/handlers/feed.rs::HashtagCount
|
||||
export interface HashtagCount {
|
||||
tag: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// mirrors backend/src/handlers/me.rs::QuotaDto
|
||||
export interface QuotaDto {
|
||||
enabled: boolean;
|
||||
used_bytes: number;
|
||||
limit_bytes: number | null;
|
||||
active_uploaders: number;
|
||||
free_disk_bytes: number;
|
||||
}
|
||||
|
||||
// mirrors backend/src/handlers/me.rs::MeContextDto
|
||||
export interface MeContextDto {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
role: 'guest' | 'host' | 'admin';
|
||||
/** Plain text — preserve whitespace and newlines on render; do **not** parse as HTML. */
|
||||
privacy_note: string;
|
||||
quota_enabled: boolean;
|
||||
storage_quota_enabled: boolean;
|
||||
}
|
||||
|
||||
// mirrors backend/src/handlers/host.rs::PinResetResponse
|
||||
export interface PinResetResponse {
|
||||
/** One-time plaintext PIN. Show it to the operator once, then forget it. */
|
||||
pin: string;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { openDB, type IDBPDatabase } from 'idb';
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { getToken } from './auth';
|
||||
import { getToken, getUserId } from './auth';
|
||||
import { refreshQuota } from './quota-store';
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
userId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
@@ -28,16 +30,37 @@ let db: IDBPDatabase | null = null;
|
||||
|
||||
async function getDb(): Promise<IDBPDatabase> {
|
||||
if (db) return db;
|
||||
db = await openDB(DB_NAME, 1, {
|
||||
upgrade(database) {
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
// v1 → v2: add `userId` index so each guest's queue is isolated on shared devices.
|
||||
// Pre-existing entries (no userId) are dropped on upgrade; nothing useful was ever
|
||||
// persisted across logouts before this version.
|
||||
db = await openDB(DB_NAME, 2, {
|
||||
upgrade(database, oldVersion) {
|
||||
if (oldVersion < 1) {
|
||||
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
if (oldVersion < 2) {
|
||||
// Wipe any pre-v2 entries — they have no userId field and would belong
|
||||
// to a now-indeterminate user. Safer to drop than to misattribute.
|
||||
const tx = database.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe every queue entry — both IndexedDB rows and the in-memory store. Called on
|
||||
* explicit logout so a second guest using the same device doesn't inherit (or be
|
||||
* blamed for) the previous guest's pending uploads.
|
||||
*/
|
||||
export async function clearQueue(): Promise<void> {
|
||||
const database = await getDb();
|
||||
await database.clear(STORE_NAME);
|
||||
queueItems.set([]);
|
||||
rateLimitRetryAt.set(null);
|
||||
}
|
||||
|
||||
class RateLimitError extends Error {
|
||||
retryAfterSecs: number;
|
||||
constructor(secs: number) {
|
||||
@@ -48,9 +71,16 @@ class RateLimitError extends Error {
|
||||
|
||||
export async function loadQueue(): Promise<void> {
|
||||
const database = await getDb();
|
||||
const myUserId = getUserId();
|
||||
const all = await database.getAll(STORE_NAME);
|
||||
const items: QueueItem[] = all.map((entry) => ({
|
||||
// Only surface entries that belong to the current user. Entries from a previous
|
||||
// guest on this device are filtered out (and would also be wiped on their next
|
||||
// explicit logout via `clearQueue`).
|
||||
const items: QueueItem[] = all
|
||||
.filter((entry) => entry.userId && entry.userId === myUserId)
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
userId: entry.userId,
|
||||
fileName: entry.fileName,
|
||||
fileSize: entry.fileSize,
|
||||
mimeType: entry.mimeType,
|
||||
@@ -69,9 +99,12 @@ export async function addToQueue(
|
||||
hashtags: string
|
||||
): Promise<void> {
|
||||
const database = await getDb();
|
||||
const userId = getUserId();
|
||||
if (!userId) return; // not authenticated — nothing to do
|
||||
const id = crypto.randomUUID();
|
||||
const entry = {
|
||||
id,
|
||||
userId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
@@ -86,6 +119,7 @@ export async function addToQueue(
|
||||
...items,
|
||||
{
|
||||
id,
|
||||
userId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
@@ -177,13 +211,21 @@ async function uploadItem(id: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
updateItemStatus(id, 'uploading');
|
||||
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
const currentUserId = getUserId();
|
||||
if (!token || !currentUserId) {
|
||||
updateItemStatus(id, 'error', 'Nicht angemeldet.');
|
||||
return;
|
||||
}
|
||||
// Defense-in-depth: if the device's signed-in user changed since this entry was
|
||||
// queued, refuse to upload it under the new identity. `loadQueue` already filters
|
||||
// by user; this guards the in-memory store path too.
|
||||
if (entry.userId && entry.userId !== currentUserId) {
|
||||
updateItemStatus(id, 'error', 'Anderer Nutzer angemeldet.');
|
||||
return;
|
||||
}
|
||||
|
||||
updateItemStatus(id, 'uploading');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -236,6 +278,9 @@ async function uploadItem(id: string): Promise<void> {
|
||||
delete entry.blob;
|
||||
await database.put(STORE_NAME, entry);
|
||||
updateItemStatus(id, 'done');
|
||||
// Refresh the per-user quota snapshot so the My Account widget reflects this
|
||||
// upload's bytes without a manual reload.
|
||||
void refreshQuota();
|
||||
} catch (e) {
|
||||
if (e instanceof RateLimitError) {
|
||||
// Reset to pending so it will be retried when the queue resumes
|
||||
|
||||
60
frontend/src/tailwind-theme.css
Normal file
60
frontend/src/tailwind-theme.css
Normal file
@@ -0,0 +1,60 @@
|
||||
/* Shared design tokens for the live app and the offline HTML-viewer export.
|
||||
* Both `frontend/src/app.css` and `frontend/export-viewer/src/app.css` import this file
|
||||
* so the keepsake stays visually in sync with the live app. Edit tokens here, rebuild
|
||||
* the export-viewer, and re-commit `backend/static/export-viewer/`.
|
||||
*
|
||||
* Tailwind v4 reads `@theme` blocks to populate utility classes; everything declared
|
||||
* here becomes a `bg-primary`, `text-accent`, `rounded-card`, etc.
|
||||
*/
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Class-based dark variant. Tailwind v4 defaults to `prefers-color-scheme`; we want
|
||||
* the user's explicit selection (saved in `theme-store.ts`) to win, so we re-bind
|
||||
* the `dark:` variant to apply whenever `<html>` has the `dark` class.
|
||||
* `:where(...)` keeps the specificity low so existing utilities still override. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
/* Brand palette — the blue used for primary buttons, FAB, active tabs. */
|
||||
--color-primary-50: #eff6ff;
|
||||
--color-primary-100: #dbeafe;
|
||||
--color-primary-500: #3b82f6;
|
||||
--color-primary-600: #2563eb;
|
||||
--color-primary-700: #1d4ed8;
|
||||
|
||||
/* Accent for hashtag chips and highlights. */
|
||||
--color-accent-500: #a855f7;
|
||||
--color-accent-600: #9333ea;
|
||||
|
||||
/* Surface scale matches the existing gray-* usage. Listed here so the viewer
|
||||
* picks up the same shade in case Tailwind defaults ever drift. */
|
||||
--color-surface-0: #ffffff;
|
||||
--color-surface-50: #f9fafb;
|
||||
--color-surface-100: #f3f4f6;
|
||||
--color-surface-200: #e5e7eb;
|
||||
|
||||
/* Typography. Keepsake should feel like the live app — same defaults. */
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", "Courier New", monospace;
|
||||
|
||||
/* Radii — keep cards and bottom sheets consistent. */
|
||||
--radius-card: 0.75rem; /* rounded-card */
|
||||
--radius-sheet: 1.25rem; /* rounded-sheet */
|
||||
}
|
||||
|
||||
/* Baseline body background + text colour so pages that haven't been re-themed yet
|
||||
* at least don't render light-on-light or dark-on-dark. Pages and cards still set
|
||||
* their own backgrounds via `bg-*` utilities, but this catches any gaps. */
|
||||
@layer base {
|
||||
html {
|
||||
background-color: #f9fafb; /* matches bg-gray-50 */
|
||||
color: #111827; /* matches text-gray-900 */
|
||||
color-scheme: light;
|
||||
}
|
||||
html.dark {
|
||||
background-color: #030712; /* matches bg-gray-950 */
|
||||
color: #f3f4f6; /* matches text-gray-100 */
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user