From 251f9f1469d5e87d0accf4c03b536e5d8d84f3fc Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 16 May 2026 14:32:37 +0200 Subject: [PATCH] frontend(plumbing): shared theme tokens, cross-cutting stores, gestures, sheets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 before paint (mirrored from theme-store.ts) so dark reloads no longer flash white. Adds 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 , 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) --- frontend/export-viewer/src/app.css | 4 +- frontend/src/app.css | 2 +- frontend/src/app.html | 16 +++ frontend/src/lib/actions/doubletap.ts | 55 +++++++++ frontend/src/lib/actions/longpress.ts | 111 ++++++++++++++++++ frontend/src/lib/auth.ts | 24 +++- .../src/lib/components/ContextSheet.svelte | 95 +++++++++++++++ frontend/src/lib/data-mode-store.ts | 56 +++++++++ frontend/src/lib/privacy-note-store.ts | 10 ++ frontend/src/lib/quota-store.ts | 38 ++++++ frontend/src/lib/sse.ts | 105 +++++++++++++++-- frontend/src/lib/theme-store.ts | 71 +++++++++++ frontend/src/lib/types.ts | 39 ++++++ frontend/src/lib/upload-queue.ts | 81 ++++++++++--- frontend/src/tailwind-theme.css | 60 ++++++++++ 15 files changed, 733 insertions(+), 34 deletions(-) create mode 100644 frontend/src/lib/actions/doubletap.ts create mode 100644 frontend/src/lib/actions/longpress.ts create mode 100644 frontend/src/lib/components/ContextSheet.svelte create mode 100644 frontend/src/lib/data-mode-store.ts create mode 100644 frontend/src/lib/privacy-note-store.ts create mode 100644 frontend/src/lib/quota-store.ts create mode 100644 frontend/src/lib/theme-store.ts create mode 100644 frontend/src/tailwind-theme.css diff --git a/frontend/export-viewer/src/app.css b/frontend/export-viewer/src/app.css index f1d8c73..43a5ae6 100644 --- a/frontend/export-viewer/src/app.css +++ b/frontend/export-viewer/src/app.css @@ -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"; diff --git a/frontend/src/app.css b/frontend/src/app.css index f1d8c73..9cc6609 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1 +1 @@ -@import "tailwindcss"; +@import "./tailwind-theme.css"; diff --git a/frontend/src/app.html b/frontend/src/app.html index 6a2bb58..7f3fa72 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -4,6 +4,22 @@ + + + %sveltekit.head% diff --git a/frontend/src/lib/actions/doubletap.ts b/frontend/src/lib/actions/doubletap.ts new file mode 100644 index 0000000..d2befa1 --- /dev/null +++ b/frontend/src/lib/actions/doubletap.ts @@ -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; +} + +export function doubletap( + node: HTMLElement, + options: DoubletapOptions = {} +): ActionReturn { + 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); + } + }; +} diff --git a/frontend/src/lib/actions/longpress.ts b/frontend/src/lib/actions/longpress.ts new file mode 100644 index 0000000..ca0d8e6 --- /dev/null +++ b/frontend/src/lib/actions/longpress.ts @@ -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: +//
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; +} + +export function longpress( + node: HTMLElement, + options: LongpressOptions = {} +): ActionReturn { + let duration = options.duration ?? 500; + let timer: ReturnType | 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); + } + }; +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index a156f66..bb261bc 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -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(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()); } diff --git a/frontend/src/lib/components/ContextSheet.svelte b/frontend/src/lib/components/ContextSheet.svelte new file mode 100644 index 0000000..552d52b --- /dev/null +++ b/frontend/src/lib/components/ContextSheet.svelte @@ -0,0 +1,95 @@ + + + + + + + + + diff --git a/frontend/src/lib/data-mode-store.ts b/frontend/src/lib/data-mode-store.ts new file mode 100644 index 0000000..171a838 --- /dev/null +++ b/frontend/src/lib/data-mode-store.ts @@ -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(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`; +} diff --git a/frontend/src/lib/privacy-note-store.ts b/frontend/src/lib/privacy-note-store.ts new file mode 100644 index 0000000..121cc5d --- /dev/null +++ b/frontend/src/lib/privacy-note-store.ts @@ -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(''); diff --git a/frontend/src/lib/quota-store.ts b/frontend/src/lib/quota-store.ts new file mode 100644 index 0000000..8bcee98 --- /dev/null +++ b/frontend/src/lib/quota-store.ts @@ -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(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 { + try { + const snap = await api.get('/me/quota'); + quotaStore.set(snap); + } catch { + // keep previous snapshot + } +} diff --git a/frontend/src/lib/sse.ts b/frontend/src/lib/sse.ts index 73de9f6..713954c 100644 --- a/frontend/src/lib/sse.ts +++ b/frontend/src/lib/sse.ts @@ -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 = new Map(); +/** Consecutive reconnect attempts since last successful onopen. Reset on success. */ +let reconnectAttempt = 0; +let reconnectTimer: ReturnType | 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 { + try { + const response = await api.get( + `/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(); } }); diff --git a/frontend/src/lib/theme-store.ts b/frontend/src/lib/theme-store.ts new file mode 100644 index 0000000..aa204e9 --- /dev/null +++ b/frontend/src/lib/theme-store.ts @@ -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 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(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 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'; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 6284560..7fc292e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; +} diff --git a/frontend/src/lib/upload-queue.ts b/frontend/src/lib/upload-queue.ts index 68cd1fe..203d8ae 100644 --- a/frontend/src/lib/upload-queue.ts +++ b/frontend/src/lib/upload-queue.ts @@ -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 { 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 { + const database = await getDb(); + await database.clear(STORE_NAME); + queueItems.set([]); + rateLimitRetryAt.set(null); +} + class RateLimitError extends Error { retryAfterSecs: number; constructor(secs: number) { @@ -48,18 +71,25 @@ class RateLimitError extends Error { export async function loadQueue(): Promise { const database = await getDb(); + const myUserId = getUserId(); const all = await database.getAll(STORE_NAME); - const items: QueueItem[] = all.map((entry) => ({ - id: entry.id, - fileName: entry.fileName, - fileSize: entry.fileSize, - mimeType: entry.mimeType, - caption: entry.caption ?? '', - hashtags: entry.hashtags ?? '', - status: entry.status === 'uploading' ? 'pending' : entry.status, - progress: entry.status === 'done' ? 100 : 0, - error: entry.error - })); + // 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, + caption: entry.caption ?? '', + hashtags: entry.hashtags ?? '', + status: entry.status === 'uploading' ? 'pending' : entry.status, + progress: entry.status === 'done' ? 100 : 0, + error: entry.error + })); queueItems.set(items); } @@ -69,9 +99,12 @@ export async function addToQueue( hashtags: string ): Promise { 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 { 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 { 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 diff --git a/frontend/src/tailwind-theme.css b/frontend/src/tailwind-theme.css new file mode 100644 index 0000000..7b18b92 --- /dev/null +++ b/frontend/src/tailwind-theme.css @@ -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 `` 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; + } +}