fix(user-flow): close offline-queue, export, session, ban & realtime gaps
Comprehensive user-flow review across guest/host/admin roles, then fixes with
e2e regression guards. Highlights:
- Offline upload queue auto-resumes on reconnect: network errors keep items
pending (not error), 4xx are terminal (no infinite retry), quota exhaustion
returns a distinct 413; queue cap + dedup.
- Export lifecycle: release atomically locks uploads; reopen invalidates and
re-release regenerates the keepsake; workers claim their job atomically so a
reopen->re-release can't corrupt the ZIP; startup re-spawns interrupted
exports.
- Sessions slide on activity (no 30-day cliff); JWT expiry deferred to the
revocable session row; sign-out-everywhere + revoke-on-PIN-reset.
- Ban always hides content (v_feed / find_visible_media / export filter
is_banned) but stays a read-only ban per USER_JOURNEYS §10 — sessions are
not revoked, read access + keepsake download preserved.
- Realtime: server-clock SSE delta cursor; event-closed/opened drive the UI
live; feed_delta rate-limited; like returns {liked, like_count} to fix
multi-device drift; lightbox live comments; diashow delta backfill.
- Forgotten-PIN in-app request flow; simultaneous same-name join returns 409;
quota increment is transactional; operator floor.
Adds e2e/specs/10-flow-review/ (offline resume, export integrity, deterministic
anti-race guard) and updates existing specs for the new contracts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -58,9 +58,21 @@
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// Pull in a comment posted from another device on the CURRENTLY-open upload, so the
|
||||
// open list matches the count. The `new-comment` broadcast carries only the count (not
|
||||
// the body), so refetch — skipped when it's for a different upload, and the dedupe
|
||||
// below makes our own just-posted optimistic comment a no-op.
|
||||
const unsubNewComment = onSseEvent('new-comment', (data) => {
|
||||
try {
|
||||
const { upload_id } = JSON.parse(data) as { upload_id: string };
|
||||
if (upload_id === upload.id) void loadComments(upload.id);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (burstTimer) clearTimeout(burstTimer);
|
||||
unsubCommentDeleted();
|
||||
unsubNewComment();
|
||||
});
|
||||
|
||||
// Only refetch when a *different* upload is shown. The feed reassigns the
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
case 'uploading': return 'Wird hochgeladen';
|
||||
case 'done': return 'Fertig';
|
||||
case 'error': return 'Fehler';
|
||||
case 'blocked': return 'Gesperrt';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +24,7 @@
|
||||
case 'uploading': return 'text-blue-600 dark:text-blue-400';
|
||||
case 'done': return 'text-green-600 dark:text-green-400';
|
||||
case 'error': return 'text-red-600 dark:text-red-400';
|
||||
case 'blocked': return 'text-red-600 dark:text-red-400';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +96,7 @@
|
||||
Erneut
|
||||
</button>
|
||||
{/if}
|
||||
{#if item.status === 'done' || item.status === 'error'}
|
||||
{#if item.status === 'done' || item.status === 'error' || item.status === 'blocked'}
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="inline-flex h-9 w-9 items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
import { scrollLock } from '$lib/actions/scroll-lock';
|
||||
import CameraCapture from '$lib/components/CameraCapture.svelte';
|
||||
import type { PendingFile } from '$lib/pending-upload-store';
|
||||
import { eventState, uploadsClosed } from '$lib/event-state-store';
|
||||
|
||||
// Uploads closed (event locked or gallery released) — show a lock notice instead of
|
||||
// the capture options, so a guest can't stage a photo that would just be rejected.
|
||||
let closed = $derived(uploadsClosed($eventState));
|
||||
|
||||
let showCamera = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
@@ -149,6 +154,22 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 px-4 pb-4 pt-2">
|
||||
{#if closed}
|
||||
<!-- Uploads closed: no capture options, just an explanation + dismiss. -->
|
||||
<div class="rounded-xl bg-amber-50 px-5 py-4 text-center dark:bg-amber-950/30">
|
||||
<p class="font-semibold text-amber-800 dark:text-amber-300">Uploads geschlossen</p>
|
||||
<p class="mt-1 text-sm text-amber-700 dark:text-amber-400">
|
||||
Der Host hat die Uploads für dieses Event beendet. Du kannst weiterhin Fotos ansehen,
|
||||
liken und kommentieren.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={close}
|
||||
class="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"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Gallery option -->
|
||||
<button
|
||||
onclick={openGallery}
|
||||
@@ -189,5 +210,6 @@
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
44
frontend/src/lib/event-state-store.ts
Normal file
44
frontend/src/lib/event-state-store.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { api } from './api';
|
||||
import type { MeContextDto } from './types';
|
||||
|
||||
/**
|
||||
* Live event lock/release state, so the composer can reflect a close/reopen the *instant*
|
||||
* it happens instead of a guest discovering the lock via a rejected upload. Seeded from
|
||||
* `/me/context` on boot and kept current by the `event-closed`/`event-opened` SSE events
|
||||
* (see the root layout, which subscribes once).
|
||||
*/
|
||||
export interface EventState {
|
||||
uploadsLocked: boolean;
|
||||
galleryReleased: boolean;
|
||||
}
|
||||
|
||||
export const eventState = writable<EventState>({ uploadsLocked: false, galleryReleased: false });
|
||||
|
||||
/** True when uploads are closed for any reason (event locked or gallery released). */
|
||||
export function uploadsClosed(s: EventState): boolean {
|
||||
return s.uploadsLocked || s.galleryReleased;
|
||||
}
|
||||
|
||||
/** Refresh from the server. Non-fatal on failure — the next SSE event reconciles. */
|
||||
export async function refreshEventState(): Promise<void> {
|
||||
try {
|
||||
const ctx = await api.get<MeContextDto>('/me/context');
|
||||
eventState.set({
|
||||
uploadsLocked: ctx.uploads_locked,
|
||||
galleryReleased: ctx.gallery_released
|
||||
});
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply an `event-closed` SSE event (uploads just locked). */
|
||||
export function markClosed(): void {
|
||||
eventState.update((s) => ({ ...s, uploadsLocked: true }));
|
||||
}
|
||||
|
||||
/** Apply an `event-opened` SSE event (uploads reopened → release also cleared). */
|
||||
export function markOpened(): void {
|
||||
eventState.set({ uploadsLocked: false, galleryReleased: false });
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { getToken } from './auth';
|
||||
import { api } from './api';
|
||||
import type { DeltaResponse } from './types';
|
||||
|
||||
type StreamTicketResponse = { ticket: string };
|
||||
type StreamTicketResponse = { ticket: string; server_time: string };
|
||||
|
||||
type EventHandler = (data: string) => void;
|
||||
|
||||
@@ -78,9 +78,11 @@ export function connectSse(): void {
|
||||
// pass that on the URL. The JWT itself never appears in URLs / access logs.
|
||||
void (async () => {
|
||||
let ticket: string;
|
||||
let serverTime: string;
|
||||
try {
|
||||
const res = await api.post<StreamTicketResponse>('/stream/ticket', {});
|
||||
ticket = res.ticket;
|
||||
serverTime = res.server_time;
|
||||
} catch {
|
||||
// Failed to mint a ticket (auth lapse, network blip). Back off and retry
|
||||
// via the existing error path.
|
||||
@@ -95,12 +97,17 @@ export function connectSse(): void {
|
||||
eventSource.onopen = () => {
|
||||
// Successful connection — reset the backoff counter.
|
||||
reconnectAttempt = 0;
|
||||
// If we have a previous timestamp this is a reconnect — fetch the gap.
|
||||
// If we have a previous timestamp this is a reconnect — fetch the gap. The
|
||||
// delta advances `lastEventTime` from the SERVER clock it returns.
|
||||
const since = lastEventTime;
|
||||
if (since) {
|
||||
void deltaFetchAndFan(since);
|
||||
} else {
|
||||
// First connect: seed the cursor from the server clock at ticket-mint time,
|
||||
// never `new Date()` — a skewed browser clock would otherwise shift the very
|
||||
// first reconnect window and could drop uploads.
|
||||
lastEventTime = serverTime;
|
||||
}
|
||||
lastEventTime = new Date().toISOString();
|
||||
};
|
||||
|
||||
for (const eventName of KNOWN_EVENTS) {
|
||||
@@ -159,7 +166,12 @@ export function setLastEventTime(time: string): void {
|
||||
}
|
||||
|
||||
function dispatch(eventType: string, data: string): void {
|
||||
lastEventTime = new Date().toISOString();
|
||||
// Advance the reconnect cursor from the SERVER timestamp carried in the payload (when
|
||||
// present — e.g. a new upload's `created_at`), never the browser clock. Events without
|
||||
// a timestamp (likes, lock toggles) leave the cursor where it is; the next delta
|
||||
// re-fetches from the last content timestamp we saw, which merges idempotently.
|
||||
const ts = extractCreatedAt(data);
|
||||
if (ts) lastEventTime = ts;
|
||||
const list = handlers.get(eventType);
|
||||
if (list) {
|
||||
for (const handler of list) {
|
||||
@@ -168,6 +180,17 @@ function dispatch(eventType: string, data: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull an ISO `created_at` out of an event payload if it has one, else undefined. */
|
||||
function extractCreatedAt(data: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed && typeof parsed.created_at === 'string') return parsed.created_at;
|
||||
} catch {
|
||||
// non-JSON payload (e.g. a plain count) — no timestamp to extract
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -179,6 +202,9 @@ async function deltaFetchAndFan(since: string): Promise<void> {
|
||||
const response = await api.get<DeltaResponse>(
|
||||
`/feed/delta?since=${encodeURIComponent(since)}`
|
||||
);
|
||||
// Advance the cursor to the server clock this delta was computed at, so the next
|
||||
// reconnect resumes exactly where the server left off (no browser-clock skew).
|
||||
lastEventTime = response.server_time;
|
||||
dispatch('feed-delta', JSON.stringify(response));
|
||||
} catch {
|
||||
// non-fatal
|
||||
|
||||
@@ -30,6 +30,9 @@ export interface DeltaResponse {
|
||||
// True when the delta hit the backend cap and is only the newest slice of the
|
||||
// gap — the client must full-refresh rather than merge (see feed-delta handler).
|
||||
truncated: boolean;
|
||||
// Server clock when this delta was computed — the next reconnect cursor advances
|
||||
// from THIS, never the browser clock, so clock skew can't silently drop uploads.
|
||||
server_time: string;
|
||||
}
|
||||
|
||||
// mirrors backend/src/handlers/feed.rs::HashtagCount
|
||||
@@ -56,6 +59,8 @@ export interface MeContextDto {
|
||||
privacy_note: string;
|
||||
quota_enabled: boolean;
|
||||
storage_quota_enabled: boolean;
|
||||
uploads_locked: boolean;
|
||||
gallery_released: boolean;
|
||||
}
|
||||
|
||||
// mirrors backend/src/handlers/host.rs::PinResetResponse
|
||||
|
||||
@@ -11,7 +11,10 @@ export interface QueueItem {
|
||||
mimeType: string;
|
||||
caption: string;
|
||||
hashtags: string;
|
||||
status: 'pending' | 'uploading' | 'done' | 'error';
|
||||
// 'error' is retryable (network / 5xx); 'blocked' is TERMINAL (a 4xx the server will
|
||||
// keep rejecting — locked event, banned user, released gallery, quota full). Blocked
|
||||
// items have had their blob purged from IndexedDB and offer no retry.
|
||||
status: 'pending' | 'uploading' | 'done' | 'error' | 'blocked';
|
||||
progress: number;
|
||||
error?: string;
|
||||
}
|
||||
@@ -26,8 +29,50 @@ export const rateLimitRetryAt = writable<number | null>(null);
|
||||
const DB_NAME = 'eventsnap-uploads';
|
||||
const STORE_NAME = 'queue';
|
||||
|
||||
/** Hard cap on queued items per device — bounds IndexedDB growth from stuck blobs. */
|
||||
const MAX_QUEUE_ITEMS = 100;
|
||||
|
||||
let db: IDBPDatabase | null = null;
|
||||
|
||||
// Resume the queue as soon as connectivity returns. Registered once, guarded for SSR.
|
||||
// This is the other half of the "flushes when you're back online" promise — without it
|
||||
// a reconnect only resumes if the user manually re-stages a file.
|
||||
let onlineBound = false;
|
||||
function bindOnline(): void {
|
||||
if (onlineBound || typeof window === 'undefined') return;
|
||||
window.addEventListener('online', () => {
|
||||
void (async () => {
|
||||
await requeueRetriable();
|
||||
await processQueue();
|
||||
})();
|
||||
});
|
||||
onlineBound = true;
|
||||
}
|
||||
bindOnline();
|
||||
|
||||
/**
|
||||
* Flip transient `error` items (5xx / a network drop that got marked before we could
|
||||
* reclassify it) back to `pending` so a resume actually retries them. Terminal `blocked`
|
||||
* items (403/413) are left alone — retrying those never succeeds.
|
||||
*/
|
||||
async function requeueRetriable(): Promise<void> {
|
||||
const database = await getDb();
|
||||
const myUserId = getUserId();
|
||||
const all = await database.getAll(STORE_NAME);
|
||||
for (const entry of all) {
|
||||
if (entry.userId === myUserId && entry.status === 'error' && entry.blob) {
|
||||
entry.status = 'pending';
|
||||
entry.error = undefined;
|
||||
await database.put(STORE_NAME, entry);
|
||||
}
|
||||
}
|
||||
queueItems.update((items) =>
|
||||
items.map((item) =>
|
||||
item.status === 'error' ? { ...item, status: 'pending' as const, progress: 0, error: undefined } : item
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function getDb(): Promise<IDBPDatabase> {
|
||||
if (db) return db;
|
||||
// v1 → v2: add `userId` index so each guest's queue is isolated on shared devices.
|
||||
@@ -76,6 +121,26 @@ class RateLimitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A permanent, non-retryable failure — the server returned a 4xx (other than 429) that
|
||||
* will never succeed on retry: uploads locked, user banned, gallery already released, or
|
||||
* per-user quota full. The item is moved to the terminal `blocked` state and its blob is
|
||||
* dropped from IndexedDB (no point keeping bytes we'll never send).
|
||||
*/
|
||||
class TerminalError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A connectivity failure — the request never reached the server (offline, DNS, dropped
|
||||
* connection). The item stays `pending` (not `error`) so the `online` listener and the
|
||||
* next `processQueue` pick it up automatically. This is what makes "the queue flushes
|
||||
* when you're back online" actually true for a file staged with no signal.
|
||||
*/
|
||||
class NetworkError extends Error {}
|
||||
|
||||
export async function loadQueue(): Promise<void> {
|
||||
const database = await getDb();
|
||||
const myUserId = getUserId();
|
||||
@@ -98,6 +163,14 @@ export async function loadQueue(): Promise<void> {
|
||||
error: entry.error
|
||||
}));
|
||||
queueItems.set(items);
|
||||
// Staged-but-unsent items from a prior session (queued offline, tab closed before
|
||||
// reconnect) must resume now — otherwise the "queue flushes when you're back online"
|
||||
// promise only holds if the user manually re-stages a file. Reclaim transient errors
|
||||
// (a network drop from a prior session) so they retry instead of stalling.
|
||||
void (async () => {
|
||||
await requeueRetriable();
|
||||
await processQueue();
|
||||
})();
|
||||
}
|
||||
|
||||
export async function addToQueue(
|
||||
@@ -108,6 +181,33 @@ export async function addToQueue(
|
||||
const database = await getDb();
|
||||
const userId = getUserId();
|
||||
if (!userId) return; // not authenticated — nothing to do
|
||||
|
||||
// Dedup: don't queue the same file twice while an identical one is still unsent
|
||||
// (double-tap, re-added after a flaky reconnect). Matches on name+size+user.
|
||||
const mine = get(queueItems).filter((i) => i.userId === userId);
|
||||
const dup = mine.some(
|
||||
(i) =>
|
||||
i.fileName === file.name &&
|
||||
i.fileSize === file.size &&
|
||||
(i.status === 'pending' || i.status === 'uploading')
|
||||
);
|
||||
if (dup) return;
|
||||
|
||||
// Cap the queue so stuck/blocked blobs can't grow IndexedDB without bound. When full,
|
||||
// evict the oldest terminal item (done/error/blocked) to make room; if none exist,
|
||||
// refuse silently rather than pile on.
|
||||
if (mine.length >= MAX_QUEUE_ITEMS) {
|
||||
const evictable = get(queueItems).find(
|
||||
(i) => i.userId === userId && (i.status === 'done' || i.status === 'error' || i.status === 'blocked')
|
||||
);
|
||||
if (evictable) {
|
||||
await database.delete(STORE_NAME, evictable.id);
|
||||
queueItems.update((items) => items.filter((it) => it.id !== evictable.id));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const entry = {
|
||||
id,
|
||||
@@ -184,6 +284,10 @@ async function processQueue(): Promise<void> {
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
// Offline: leave items 'pending' rather than burning through them into 'error'.
|
||||
// The `online` listener re-enters here the moment connectivity returns.
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) break;
|
||||
|
||||
const items = get(queueItems);
|
||||
const next = items.find((item) => item.status === 'pending');
|
||||
if (!next) break;
|
||||
@@ -201,7 +305,12 @@ async function processQueue(): Promise<void> {
|
||||
}, e.retryAfterSecs * 1000);
|
||||
break;
|
||||
}
|
||||
// Other errors are already handled inside uploadItem (marked as 'error')
|
||||
if (e instanceof NetworkError) {
|
||||
// Connectivity dropped mid-flight — the item is back to 'pending'. Stop the
|
||||
// loop; the `online` listener resumes it. No error state, no manual tap.
|
||||
break;
|
||||
}
|
||||
// Other errors are already handled inside uploadItem (marked 'error'/'blocked')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -258,6 +367,8 @@ async function uploadItem(id: string): Promise<void> {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else if (xhr.status === 429) {
|
||||
// Rate limit only (quota-full is now a distinct 413, handled below as
|
||||
// terminal) — back off and auto-resume when the window lifts.
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
const secs = typeof body.retry_after_secs === 'number' ? body.retry_after_secs : 60;
|
||||
@@ -265,7 +376,20 @@ async function uploadItem(id: string): Promise<void> {
|
||||
} catch {
|
||||
reject(new RateLimitError(60));
|
||||
}
|
||||
} else if (xhr.status >= 400 && xhr.status < 500) {
|
||||
// Any other 4xx is permanent for this blob (locked/banned/released/quota).
|
||||
// Retrying just re-hits the same rejection forever, so mark it terminal.
|
||||
let msg = 'Upload nicht möglich.';
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
if (body.message) msg = body.message;
|
||||
else if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
|
||||
} catch {
|
||||
if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
|
||||
}
|
||||
reject(new TerminalError(msg));
|
||||
} else {
|
||||
// 5xx / unexpected — transient, keep it retryable.
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
reject(new Error(body.message || `HTTP ${xhr.status}`));
|
||||
@@ -275,8 +399,8 @@ async function uploadItem(id: string): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Netzwerkfehler')));
|
||||
xhr.addEventListener('abort', () => reject(new Error('Abgebrochen')));
|
||||
xhr.addEventListener('error', () => reject(new NetworkError('Netzwerkfehler')));
|
||||
xhr.addEventListener('abort', () => reject(new NetworkError('Abgebrochen')));
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
@@ -296,6 +420,24 @@ async function uploadItem(id: string): Promise<void> {
|
||||
updateItemStatus(id, 'pending');
|
||||
throw e; // Propagate to processQueue for scheduling
|
||||
}
|
||||
if (e instanceof NetworkError) {
|
||||
// No connectivity — keep the item pending and propagate so processQueue stops
|
||||
// the loop (no point hammering while offline). The `online` listener resumes it.
|
||||
entry.status = 'pending';
|
||||
await database.put(STORE_NAME, entry);
|
||||
updateItemStatus(id, 'pending');
|
||||
throw e;
|
||||
}
|
||||
if (e instanceof TerminalError) {
|
||||
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
|
||||
// so the UI shows a clear reason and offers no retry.
|
||||
delete entry.blob;
|
||||
entry.status = 'blocked';
|
||||
entry.error = e.message;
|
||||
await database.put(STORE_NAME, entry);
|
||||
updateItemStatus(id, 'blocked', e.message);
|
||||
return;
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.';
|
||||
entry.status = 'error';
|
||||
entry.error = msg;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { onSseEvent } from '$lib/sse';
|
||||
import { api } from '$lib/api';
|
||||
import type { MeContextDto } from '$lib/types';
|
||||
import { eventState, markClosed, markOpened } from '$lib/event-state-store';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -39,6 +40,10 @@
|
||||
try {
|
||||
const ctx = await api.get<MeContextDto>('/me/context');
|
||||
privacyNote.set(ctx.privacy_note);
|
||||
eventState.set({
|
||||
uploadsLocked: ctx.uploads_locked,
|
||||
galleryReleased: ctx.gallery_released
|
||||
});
|
||||
} catch {
|
||||
// Cross-cutting hydration on boot — failure is non-fatal; users without
|
||||
// a session land on /join anyway, and the per-page mount will retry.
|
||||
@@ -61,7 +66,11 @@
|
||||
} catch {
|
||||
// Malformed payload — discard; nothing actionable for the user.
|
||||
}
|
||||
})
|
||||
}),
|
||||
// Reflect a host closing/reopening uploads live, so the composer switches to a
|
||||
// locked state immediately instead of a guest finding out via a rejected upload.
|
||||
onSseEvent('event-closed', () => markClosed()),
|
||||
onSseEvent('event-opened', () => markOpened())
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
let expiry = $state<Date | null>(null);
|
||||
let pinCopied = $state(false);
|
||||
let leaveConfirmOpen = $state(false);
|
||||
let leaveEverywhere = $state(false);
|
||||
let dataModeWarningOpen = $state(false);
|
||||
|
||||
// `pin` is sourced from the shared `currentPin` store so a global pin-reset SSE
|
||||
@@ -99,10 +100,14 @@
|
||||
setTimeout(() => (pinCopied = false), 2000);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
async function handleLogout(everywhere = false) {
|
||||
// Session-delete is best-effort: the JWT is going away on this device either way,
|
||||
// so a network failure shouldn't block the user from leaving the event.
|
||||
try { await api.delete('/session'); } catch { /* best-effort logout */ }
|
||||
// `everywhere` revokes ALL of this user's sessions (other phones/laptops recovered
|
||||
// via PIN), not just this device.
|
||||
try {
|
||||
await api.delete(everywhere ? '/sessions' : '/session');
|
||||
} catch { /* best-effort logout */ }
|
||||
// Wipe the IndexedDB upload queue so a second guest using the same device can't
|
||||
// inherit (or be blamed for) this guest's pending uploads. Not done on a 401
|
||||
// auto-clear — that path preserves the queue in case the user re-authenticates.
|
||||
@@ -379,9 +384,9 @@
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Leave / logout -->
|
||||
<!-- Leave / logout (this device) -->
|
||||
<button
|
||||
onclick={() => (leaveConfirmOpen = true)}
|
||||
onclick={() => { leaveEverywhere = false; leaveConfirmOpen = true; }}
|
||||
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
|
||||
>
|
||||
<svg class="h-5 w-5 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -389,6 +394,17 @@
|
||||
</svg>
|
||||
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400">Event verlassen</span>
|
||||
</button>
|
||||
|
||||
<!-- Sign out everywhere (all devices) -->
|
||||
<button
|
||||
onclick={() => { leaveEverywhere = true; leaveConfirmOpen = true; }}
|
||||
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
|
||||
>
|
||||
<svg class="h-5 w-5 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400">Auf allen Geräten abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -435,10 +451,12 @@
|
||||
<!-- Leave-confirm bottom sheet -->
|
||||
<ConfirmSheet
|
||||
open={leaveConfirmOpen}
|
||||
title="Event verlassen?"
|
||||
message="Du wirst abgemeldet. Mit deinem PIN kannst du jederzeit zurückkehren."
|
||||
title={leaveEverywhere ? 'Auf allen Geräten abmelden?' : 'Event verlassen?'}
|
||||
message={leaveEverywhere
|
||||
? 'Alle deine Sitzungen (auch auf anderen Geräten) werden beendet. Mit deinem PIN kannst du jederzeit zurückkehren.'
|
||||
: 'Du wirst abgemeldet. Mit deinem PIN kannst du jederzeit zurückkehren.'}
|
||||
confirmLabel="Abmelden"
|
||||
tone="danger"
|
||||
onConfirm={handleLogout}
|
||||
onConfirm={() => handleLogout(leaveEverywhere)}
|
||||
onCancel={() => (leaveConfirmOpen = false)}
|
||||
/>
|
||||
|
||||
@@ -127,7 +127,6 @@
|
||||
|
||||
// Ban modal state
|
||||
let banTarget = $state<UserSummary | null>(null);
|
||||
let banHideUploads = $state(false);
|
||||
let banSubmitting = $state(false);
|
||||
|
||||
// PIN reset state — `pinModal` holds the freshly-issued plaintext PIN. We forget it
|
||||
@@ -254,14 +253,13 @@
|
||||
|
||||
function openBanModal(user: UserSummary) {
|
||||
banTarget = user;
|
||||
banHideUploads = false;
|
||||
}
|
||||
|
||||
async function confirmBan() {
|
||||
if (!banTarget) return;
|
||||
banSubmitting = true;
|
||||
try {
|
||||
await api.post(`/host/users/${banTarget.id}/ban`, { hide_uploads: banHideUploads });
|
||||
await api.post(`/host/users/${banTarget.id}/ban`, {});
|
||||
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
|
||||
banTarget = null;
|
||||
users = await api.get<UserSummary[]>('/host/users');
|
||||
@@ -416,17 +414,14 @@
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<!-- Ban modal — checkbox-bearing, so uses the Modal shell instead of ConfirmSheet. -->
|
||||
<!-- Ban modal — ban always hides now, so this is a plain confirm (no checkbox). -->
|
||||
<Modal open={banTarget !== null} titleId="admin-ban-modal-title" onClose={() => (banTarget = null)}>
|
||||
{#if banTarget}
|
||||
<h2 id="admin-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
|
||||
<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus
|
||||
Galerie, Diashow und Export, und die Sitzung wird beendet. Rückgängig machbar über „Entsperren“.
|
||||
</p>
|
||||
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<input type="checkbox" bind:checked={banHideUploads} class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button onclick={() => (banTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800">Abbrechen</button>
|
||||
<button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 active:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { SlideQueue } from '$lib/diashow/queue';
|
||||
import { transitions, findTransition } from '$lib/diashow/transitions';
|
||||
import { acquireWakeLock, releaseWakeLock } from '$lib/diashow/wakelock';
|
||||
import type { FeedUpload, FeedResponse } from '$lib/types';
|
||||
import type { FeedUpload, FeedResponse, DeltaResponse } from '$lib/types';
|
||||
|
||||
const DWELL_OPTIONS = [3000, 6000, 10000];
|
||||
|
||||
@@ -100,6 +100,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
// After an all-night projector reconnects (SSE drop, network blip), the live events
|
||||
// it missed are gone. The SSE client fans out a `feed-delta` of everything since the
|
||||
// last seen timestamp — merge it so the show backfills the gap instead of silently
|
||||
// stalling on a stale set.
|
||||
function handleFeedDelta(data: string) {
|
||||
try {
|
||||
const delta = JSON.parse(data) as DeltaResponse;
|
||||
for (const id of delta.deleted_ids) {
|
||||
const result = queue.remove(id, current?.id ?? null);
|
||||
if (result.wasCurrent) advance();
|
||||
}
|
||||
for (const upload of delta.uploads) {
|
||||
// Only enqueue displayable (processed) items; pushLive dedupes by id, so a
|
||||
// later `upload-processed` still refines anything that arrives raw.
|
||||
if (upload.preview_url || upload.thumbnail_url) queue.pushLive(upload);
|
||||
}
|
||||
if (!current) advance();
|
||||
} catch {
|
||||
// ignore — non-fatal; the next live event keeps the show moving
|
||||
}
|
||||
}
|
||||
|
||||
function handleUserHidden(data: string) {
|
||||
try {
|
||||
const payload = JSON.parse(data) as { user_id: string };
|
||||
@@ -164,6 +186,7 @@
|
||||
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
||||
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
|
||||
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
|
||||
unsubs.push(onSseEvent('feed-delta', handleFeedDelta));
|
||||
void loadInitial();
|
||||
});
|
||||
|
||||
|
||||
@@ -389,18 +389,24 @@
|
||||
|
||||
async function handleLike(id: string) {
|
||||
try {
|
||||
await api.post(`/upload/${id}/like`);
|
||||
// Set state from the server's authoritative response rather than blind-inverting
|
||||
// local state. On a second device (same recovered user), the `like-update`
|
||||
// broadcast only carries `like_count` — so a blind invert would drift
|
||||
// `liked_by_me` until refresh. The response gives both, exactly.
|
||||
const res = await api.post<{ liked: boolean; like_count: number | null }>(`/upload/${id}/like`);
|
||||
vibrate(10);
|
||||
// like_count is null when the server's count query hiccuped — keep the current
|
||||
// count in that case rather than adopting a wrong number.
|
||||
uploads = uploads.map((u) =>
|
||||
u.id === id
|
||||
? { ...u, liked_by_me: !u.liked_by_me, like_count: u.liked_by_me ? u.like_count - 1 : u.like_count + 1 }
|
||||
? { ...u, liked_by_me: res.liked, like_count: res.like_count ?? u.like_count }
|
||||
: u
|
||||
);
|
||||
if (selectedUpload?.id === id) {
|
||||
selectedUpload = {
|
||||
...selectedUpload,
|
||||
liked_by_me: !selectedUpload.liked_by_me,
|
||||
like_count: selectedUpload.liked_by_me ? selectedUpload.like_count - 1 : selectedUpload.like_count + 1,
|
||||
liked_by_me: res.liked,
|
||||
like_count: res.like_count ?? selectedUpload.like_count,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -27,8 +27,16 @@
|
||||
export_released: boolean;
|
||||
}
|
||||
|
||||
interface PinResetRequest {
|
||||
id: string;
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
let event = $state<EventStatus | null>(null);
|
||||
let users = $state<UserSummary[]>([]);
|
||||
let pinResetRequests = $state<PinResetRequest[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
@@ -47,7 +55,6 @@
|
||||
|
||||
// Ban modal state
|
||||
let banTarget = $state<UserSummary | null>(null);
|
||||
let banHideUploads = $state(false);
|
||||
let banSubmitting = $state(false);
|
||||
|
||||
// PIN reset modal state. `pinModal` holds the freshly-issued plaintext PIN; it is
|
||||
@@ -114,9 +121,10 @@
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
[event, users] = await Promise.all([
|
||||
[event, users, pinResetRequests] = await Promise.all([
|
||||
api.get<EventStatus>('/host/event'),
|
||||
api.get<UserSummary[]>('/host/users')
|
||||
api.get<UserSummary[]>('/host/users'),
|
||||
api.get<PinResetRequest[]>('/host/pin-reset-requests')
|
||||
]);
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
|
||||
@@ -125,6 +133,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPinForRequest(req: PinResetRequest) {
|
||||
try {
|
||||
const res = await api.post<{ pin: string }>(`/host/users/${req.user_id}/pin-reset`);
|
||||
pinModal = { name: req.display_name, pin: res.pin };
|
||||
await reload();
|
||||
} catch (e: unknown) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissPinRequest(req: PinResetRequest) {
|
||||
try {
|
||||
await api.delete(`/host/pin-reset-requests/${req.id}`);
|
||||
pinResetRequests = pinResetRequests.filter((r) => r.id !== req.id);
|
||||
} catch (e: unknown) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEventLock() {
|
||||
if (!event) return;
|
||||
try {
|
||||
@@ -153,14 +180,13 @@
|
||||
|
||||
function openBanModal(user: UserSummary) {
|
||||
banTarget = user;
|
||||
banHideUploads = false;
|
||||
}
|
||||
|
||||
async function confirmBan() {
|
||||
if (!banTarget) return;
|
||||
banSubmitting = true;
|
||||
try {
|
||||
await api.post(`/host/users/${banTarget.id}/ban`, { hide_uploads: banHideUploads });
|
||||
await api.post(`/host/users/${banTarget.id}/ban`, {});
|
||||
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
|
||||
banTarget = null;
|
||||
await reload();
|
||||
@@ -278,21 +304,14 @@
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<!-- Ban modal — needs a checkbox so it's not a pure ConfirmSheet, but still gets the same a11y shell. -->
|
||||
<!-- Ban modal — ban always hides now, so this is a plain confirm (no checkbox). -->
|
||||
<Modal open={banTarget !== null} titleId="host-ban-modal-title" onClose={() => (banTarget = null)}>
|
||||
{#if banTarget}
|
||||
<h2 id="host-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
|
||||
<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus
|
||||
Galerie, Diashow und Export, und die Sitzung wird beendet. Rückgängig machbar über „Entsperren“.
|
||||
</p>
|
||||
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={banHideUploads}
|
||||
class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => (banTarget = null)}
|
||||
@@ -336,6 +355,41 @@
|
||||
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">{error}</div>
|
||||
{:else if event}
|
||||
|
||||
<!-- ── PIN-Reset-Anfragen ──────────────────────────────────────── -->
|
||||
{#if pinResetRequests.length > 0}
|
||||
<div class="overflow-hidden rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<div class="border-b border-amber-200 px-5 py-3 dark:border-amber-900">
|
||||
<h2 class="font-semibold text-amber-900 dark:text-amber-200">
|
||||
PIN vergessen — {pinResetRequests.length} Anfrage{pinResetRequests.length === 1 ? '' : 'n'}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-xs text-amber-700 dark:text-amber-400">
|
||||
Prüfe die Identität, bevor du eine PIN zurücksetzt.
|
||||
</p>
|
||||
</div>
|
||||
<ul class="divide-y divide-amber-200 dark:divide-amber-900">
|
||||
{#each pinResetRequests as req (req.id)}
|
||||
<li class="flex items-center justify-between gap-3 px-5 py-3">
|
||||
<span class="min-w-0 truncate font-medium text-amber-900 dark:text-amber-100">{req.display_name}</span>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
onclick={() => dismissPinRequest(req)}
|
||||
class="rounded-lg border border-amber-300 px-3 py-1.5 text-sm text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-200 dark:hover:bg-amber-900/40"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
onclick={() => resetPinForRequest(req)}
|
||||
class="rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-400"
|
||||
>
|
||||
PIN zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Statistiken ─────────────────────────────────────────────── -->
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<button
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
let recoveryPin = $state('');
|
||||
let recoveryError = $state('');
|
||||
let recoveryLoading = $state(false);
|
||||
// Forgot-PIN request state (asks a host to reset it).
|
||||
let pinRequestSent = $state(false);
|
||||
let pinRequestLoading = $state(false);
|
||||
|
||||
async function handleJoin() {
|
||||
if (!displayName.trim()) return;
|
||||
@@ -85,9 +88,24 @@
|
||||
nameTaken = false;
|
||||
recoveryPin = '';
|
||||
recoveryError = '';
|
||||
pinRequestSent = false;
|
||||
// Keep displayName so the user can edit it slightly
|
||||
}
|
||||
|
||||
// Forgot the PIN entirely: ask a host to reset it. The endpoint always 204s (no name
|
||||
// enumeration), so we optimistically show a confirmation regardless.
|
||||
async function requestPinReset() {
|
||||
pinRequestLoading = true;
|
||||
try {
|
||||
await api.post('/recover/request', { display_name: takenName });
|
||||
} catch {
|
||||
// Non-fatal (rate limit etc.) — still show the confirmation so the user isn't stuck.
|
||||
} finally {
|
||||
pinRequestLoading = false;
|
||||
pinRequestSent = true;
|
||||
}
|
||||
}
|
||||
|
||||
function copyPin() {
|
||||
navigator.clipboard.writeText(pin);
|
||||
copied = true;
|
||||
@@ -173,6 +191,23 @@
|
||||
Anderen Namen wählen
|
||||
</button>
|
||||
|
||||
<!-- Forgot the PIN entirely — ask a host to reset it in-app. -->
|
||||
{#if pinRequestSent}
|
||||
<p class="mt-3 rounded-lg bg-green-50 px-4 py-3 text-center text-sm text-green-700 dark:bg-green-950/30 dark:text-green-300">
|
||||
Anfrage gesendet. Bitte einen Host, deine PIN zurückzusetzen — danach kannst du dich
|
||||
mit der neuen PIN anmelden.
|
||||
</p>
|
||||
{:else}
|
||||
<button
|
||||
onclick={requestPinReset}
|
||||
disabled={pinRequestLoading}
|
||||
data-testid="request-pin-reset"
|
||||
class="mt-3 w-full text-center text-sm text-blue-600 underline decoration-dotted underline-offset-2 hover:text-blue-700 disabled:opacity-50 dark:text-blue-400"
|
||||
>
|
||||
{pinRequestLoading ? 'Wird gesendet…' : 'PIN vergessen? Host um Zurücksetzen bitten'}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<!-- Normal join form -->
|
||||
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Willkommen!</h1>
|
||||
|
||||
Reference in New Issue
Block a user