fix(frontend): surface upload errors, push ban/lock, route guards (WS8)
H11: UploadSheet now honors the modal contract — role=dialog/aria-modal + accessible name, a focus-trap/Escape/focus-restore $effect mirroring ContextSheet, and inert + pointer-events-none when closed so its controls leave the tab order and AT tree. H12: a layout-level $effect toasts each upload-queue item the first time it transitions to 'error', so a banned/locked/over-quota guest is actually told why their photo didn't post instead of the composer silently closing. M10: api.ts now redirects to /join after clearAuth() on a 401 (guarded against loops on the auth screens), so a 401 no longer strands the user on a dead page. M11: ban and event-lock are pushed to guests. A new uploadsLocked store is seeded from /me/context (now exposes uploads_locked) and kept live by event-closed/opened SSE; the feed shows a banner and the FAB is disabled when locked. A user-banned SSE event force-logs-out the targeted user. M19: feed and export pages track a distinct loadError and render an "Erneut versuchen" retry card instead of collapsing a fetch failure into "empty" / "not released". Light-mode empty-state text bumped to gray-500 for contrast. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { getToken, clearAuth } from './auth';
|
||||
|
||||
const BASE = '/api/v1';
|
||||
@@ -42,6 +44,11 @@ async function request<T>(
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
clearAuth();
|
||||
// Don't strand the user on a now-unauthenticated page with no nav —
|
||||
// send them to /join. Guard against loops on the auth screens.
|
||||
if (browser && !/^\/(join|recover|admin\/login)/.test(window.location.pathname)) {
|
||||
void goto('/join');
|
||||
}
|
||||
}
|
||||
throw new ApiError(res.status, data.error ?? 'unknown', data.message ?? 'Fehler');
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { uploadSheetOpen, uploadBadgeCount } from '$lib/ui-store';
|
||||
import { exportStatus, initExportStatus } from '$lib/export-status-store';
|
||||
import { uploadsLocked } from '$lib/event-state-store';
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
return $page.url.pathname.startsWith(path);
|
||||
@@ -43,8 +44,9 @@
|
||||
<div class="relative -translate-y-3">
|
||||
<button
|
||||
onclick={() => ($uploadSheetOpen = true)}
|
||||
class="relative flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition active:scale-95 hover:bg-blue-700"
|
||||
aria-label="Hochladen"
|
||||
disabled={$uploadsLocked}
|
||||
class="relative flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition active:scale-95 hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-blue-600 disabled:active:scale-100"
|
||||
aria-label={$uploadsLocked ? 'Uploads gesperrt' : 'Hochladen'}
|
||||
>
|
||||
<!-- Camera + plus icon -->
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
let showCamera = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
let sheet = $state<HTMLDivElement | null>(null);
|
||||
let returnFocus: HTMLElement | null = null;
|
||||
|
||||
// Keep the sheet and backdrop always in the DOM for smooth CSS transitions.
|
||||
let open = $derived($uploadSheetOpen);
|
||||
@@ -15,6 +17,50 @@
|
||||
uploadSheetOpen.set(false);
|
||||
}
|
||||
|
||||
// Modal contract (mirrors ContextSheet): trap Tab within the sheet, close on
|
||||
// Escape, and restore focus on close. The sheet is permanently mounted (CSS
|
||||
// translate animation), so this is wired via $effect — and `inert`/`aria-hidden`
|
||||
// below keep its controls out of the tab order + AT tree while closed.
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab' || !sheet) return;
|
||||
const list = Array.from(sheet.querySelectorAll<HTMLElement>('button:not([disabled])'));
|
||||
if (list.length === 0) return;
|
||||
const first = list[0];
|
||||
const last = list[list.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (e.shiftKey && (active === first || !sheet.contains(active))) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
returnFocus = (document.activeElement as HTMLElement | null) ?? null;
|
||||
requestAnimationFrame(() => {
|
||||
const first = sheet?.querySelector<HTMLButtonElement>('button:not([disabled])');
|
||||
first?.focus({ preventScroll: true });
|
||||
});
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
} else if (returnFocus) {
|
||||
try {
|
||||
returnFocus.focus({ preventScroll: true });
|
||||
} catch {
|
||||
/* element gone */
|
||||
}
|
||||
returnFocus = null;
|
||||
}
|
||||
});
|
||||
|
||||
function openGallery() {
|
||||
fileInput?.click();
|
||||
}
|
||||
@@ -80,10 +126,17 @@
|
||||
|
||||
<!-- Sheet -->
|
||||
<div
|
||||
bind:this={sheet}
|
||||
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white transition-transform duration-300 dark:bg-gray-900"
|
||||
class:translate-y-full={!open}
|
||||
class:translate-y-0={open}
|
||||
class:pointer-events-none={!open}
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Hochladen"
|
||||
tabindex="-1"
|
||||
inert={!open}
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div class="flex justify-center pt-3 pb-1">
|
||||
|
||||
10
frontend/src/lib/event-state-store.ts
Normal file
10
frontend/src/lib/event-state-store.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Shared, reactive event state pushed from the backend.
|
||||
//
|
||||
// `uploadsLocked` is seeded from `/me/context` on boot and kept live by the
|
||||
// `event-closed` / `event-opened` SSE events (wired in the root layout). Pages
|
||||
// read it to show an "Uploads gesperrt" banner and disable the upload FAB, so a
|
||||
// locked event stops inviting uploads that would only fail server-side.
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const uploadsLocked = writable<boolean>(false);
|
||||
@@ -55,6 +55,7 @@ export interface MeContextDto {
|
||||
privacy_note: string;
|
||||
quota_enabled: boolean;
|
||||
storage_quota_enabled: boolean;
|
||||
uploads_locked: boolean;
|
||||
}
|
||||
|
||||
// mirrors backend/src/handlers/host.rs::PinResetResponse
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import '../app.css';
|
||||
import { initAuth, getToken, getUserId, clearPin } from '$lib/auth';
|
||||
import { initAuth, getToken, getUserId, clearPin, clearAuth } from '$lib/auth';
|
||||
import { initTheme } from '$lib/theme-store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import BottomNav from '$lib/components/BottomNav.svelte';
|
||||
import UploadSheet from '$lib/components/UploadSheet.svelte';
|
||||
@@ -14,12 +15,28 @@
|
||||
import { refreshQuota } from '$lib/quota-store';
|
||||
import { onSseEvent } from '$lib/sse';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast-store';
|
||||
import { uploadsLocked } from '$lib/event-state-store';
|
||||
import type { MeContextDto } from '$lib/types';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let unsubs: Array<() => void> = [];
|
||||
|
||||
// H12: surface upload failures. The queue marks failed items 'error' in
|
||||
// IndexedDB but nothing rendered them, so a banned/locked/over-quota guest saw
|
||||
// the composer close to a normal feed and assumed success. Toast each item the
|
||||
// first time it transitions to 'error'.
|
||||
let toastedErrors = new Set<string>();
|
||||
$effect(() => {
|
||||
for (const item of $queueItems) {
|
||||
if (item.status === 'error' && !toastedErrors.has(item.id)) {
|
||||
toastedErrors.add(item.id);
|
||||
toast(item.error ?? 'Upload fehlgeschlagen.', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Slim progress bar: ratio of completed items to total, shown while processing.
|
||||
let progressPct = $derived.by(() => {
|
||||
const total = $queueItems.length;
|
||||
@@ -39,6 +56,7 @@
|
||||
try {
|
||||
const ctx = await api.get<MeContextDto>('/me/context');
|
||||
privacyNote.set(ctx.privacy_note);
|
||||
uploadsLocked.set(ctx.uploads_locked);
|
||||
} 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 +79,26 @@
|
||||
} catch {
|
||||
// Malformed payload — discard; nothing actionable for the user.
|
||||
}
|
||||
})
|
||||
}),
|
||||
// M11: a host banned someone. If it's us, the session has already been
|
||||
// revoked server-side — drop local auth and send us to /join so the UI
|
||||
// doesn't keep pretending we're signed in.
|
||||
onSseEvent('user-banned', (data) => {
|
||||
try {
|
||||
const payload = JSON.parse(data) as { user_id: string };
|
||||
if (payload.user_id === getUserId()) {
|
||||
clearAuth();
|
||||
toast('Du wurdest vom Event entfernt.', 'error');
|
||||
void goto('/join');
|
||||
}
|
||||
} catch {
|
||||
/* malformed — ignore */
|
||||
}
|
||||
}),
|
||||
// M11: reflect event lock state live so the feed/upload pages can toggle
|
||||
// their "Uploads gesperrt" banner and disable the FAB.
|
||||
onSseEvent('event-closed', () => uploadsLocked.set(true)),
|
||||
onSseEvent('event-opened', () => uploadsLocked.set(false))
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
let status = $state<ExportStatus | null>(null);
|
||||
let showHtmlGuide = $state(false);
|
||||
let loading = $state(true);
|
||||
let loadError = $state(false);
|
||||
|
||||
let unsubscribers: (() => void)[] = [];
|
||||
|
||||
@@ -53,9 +54,12 @@
|
||||
async function loadStatus() {
|
||||
try {
|
||||
status = await api.get<ExportStatus>('/export/status');
|
||||
loadError = false;
|
||||
} catch {
|
||||
// Background poll triggered by SSE — silent. The visible empty/loading state
|
||||
// will reflect the failure; the next event will retry.
|
||||
// M19: a fetch failure must not masquerade as "not yet released" — only
|
||||
// flag an error when we have nothing to show; a background SSE poll that
|
||||
// fails while we already have a status stays silent.
|
||||
if (!status) loadError = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -174,6 +178,16 @@
|
||||
<div class="mx-auto max-w-lg space-y-4 p-4">
|
||||
{#if loading}
|
||||
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
|
||||
{:else if loadError}
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="font-medium text-gray-700 dark:text-gray-300">Status konnte nicht geladen werden.</p>
|
||||
<button
|
||||
onclick={() => { loading = true; loadStatus(); }}
|
||||
class="mt-3 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
{:else if !status?.released}
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { toast, toastError } from '$lib/toast-store';
|
||||
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
|
||||
import { vibrate } from '$lib/haptics';
|
||||
import { uploadsLocked } from '$lib/event-state-store';
|
||||
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
|
||||
|
||||
let uploads = $state<FeedUpload[]>([]);
|
||||
@@ -24,6 +25,7 @@
|
||||
let nextCursor = $state<string | null>(null);
|
||||
let loadingMore = $state(false);
|
||||
let initialLoading = $state(true);
|
||||
let loadError = $state(false);
|
||||
let refreshing = $state(false);
|
||||
let pullProgress = $state(0); // 0–1+ during the drag, 0 when idle
|
||||
let selectedUpload = $state<FeedUpload | null>(null);
|
||||
@@ -246,13 +248,18 @@
|
||||
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
||||
uploads = res.uploads;
|
||||
nextCursor = res.next_cursor;
|
||||
loadError = false;
|
||||
// Seed the SSE reconnect cursor from the newest server timestamp so the
|
||||
// delta on the next reconnect is based on server time, not the client
|
||||
// clock (M9).
|
||||
if (uploads.length) setLastEventTime(uploads[0].created_at);
|
||||
} catch (e) {
|
||||
// Initial / user-triggered refresh is worth surfacing — background SSE refetches are noisier and silenced below.
|
||||
if (!refresh) toastError(e);
|
||||
// M19: distinguish a load failure from a genuinely empty gallery so the
|
||||
// template can offer a retry instead of showing "nobody posted yet".
|
||||
if (!refresh) {
|
||||
loadError = true;
|
||||
toastError(e);
|
||||
}
|
||||
} finally {
|
||||
initialLoading = false;
|
||||
}
|
||||
@@ -524,6 +531,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Uploads-locked banner (M11) -->
|
||||
{#if $uploadsLocked}
|
||||
<div
|
||||
class="mx-auto mb-2 max-w-2xl rounded-lg bg-amber-100 px-4 py-2 text-center text-sm font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
|
||||
role="status"
|
||||
>
|
||||
Uploads sind aktuell gesperrt.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
{#if initialLoading && uploads.length === 0}
|
||||
<div class="mx-auto max-w-2xl" data-testid="feed-skeleton">
|
||||
@@ -539,10 +556,20 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if uploads.length === 0 && loadError}
|
||||
<div class="py-20 text-center">
|
||||
<p class="text-lg text-gray-600 dark:text-gray-300">Galerie konnte nicht geladen werden.</p>
|
||||
<button
|
||||
onclick={() => loadFeed()}
|
||||
class="mt-3 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
{:else if uploads.length === 0}
|
||||
<div class="py-20 text-center">
|
||||
<p class="text-lg text-gray-400 dark:text-gray-500">Noch keine Fotos.</p>
|
||||
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Tippe auf den Plus-Button unten!</p>
|
||||
<p class="text-lg text-gray-500 dark:text-gray-400">Noch keine Fotos.</p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Tippe auf den Plus-Button unten!</p>
|
||||
</div>
|
||||
{:else if viewMode === 'list'}
|
||||
<!-- List view: chronological full-width cards -->
|
||||
|
||||
Reference in New Issue
Block a user