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;
+ }
+}