Files
EventSnap/frontend/src/routes/+layout.svelte
fabi da2d4f67e7
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m40s
E2E / Cross-UA smoke matrix (push) Has been cancelled
E2E / Playwright E2E (chromium + webkit) (push) Has been cancelled
Audit / npm audit (frontend) (push) Has been cancelled
Checks / Backend — cargo test + clippy + fmt (push) Has been cancelled
Checks / Frontend — vitest + svelte-check (push) Has been cancelled
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Has been cancelled
Checks / E2E — typecheck + lint (push) Has been cancelled
fix(icon): wedding rings, and delete the skeleton favicon behind them
The event is a wedding, so the camera icon becomes two interlocking bands. Drawn rather
than sourced: at 16px in a tab anything finer than the stroke turns to smudge, which is
why the camera it replaces was three shapes and no more. White on #8a6a2b, the event's
own theme_primary, so the icon, the site and the PWA chrome finally agree -- the
manifest's theme_color was still the #2563eb from the camera branding.

The rings span x 88-424 and y 139-373 including stroke, inside the centre-80% safe zone,
so an Android circle crop cannot clip them.

Also removes a second, competing icon declaration. `+layout.svelte` imported
`$lib/assets/favicon.svg` -- the orange Svelte logo from the project skeleton, which Vite
inlined as a data URI into the layout bundle -- and applied it via `<svelte:head>`. Which
of the two won was left to the browser: the link in app.html is parsed from the initial
HTML, this one is added at hydration, and Chrome keeps the former. So the skeleton logo
did not usually show, but nothing guaranteed that, and `ssr = false` means the branded
link is the only one present at first paint anyway. app.html is now the single source and
the skeleton asset is gone.
2026-08-18 18:03:31 +00:00

304 lines
14 KiB
Svelte

<script lang="ts">
import '../app.css';
import { initAuth, getToken, getUserId, clearPin } from '$lib/auth';
import { initTheme } from '$lib/theme-store';
import { onMount, onDestroy } from 'svelte';
import BottomNav from '$lib/components/BottomNav.svelte';
import UploadSheet from '$lib/components/UploadSheet.svelte';
import Toaster from '$lib/components/Toaster.svelte';
import { showBottomNav } from '$lib/ui-store';
import { isAuthenticated } from '$lib/auth';
import {
queueItems,
isProcessing,
loadQueue,
rateLimitRetryAt,
releaseResolvedParks
} from '$lib/upload-queue';
import { privacyNote } from '$lib/privacy-note-store';
import { refreshQuota } from '$lib/quota-store';
import { onSseEvent } from '$lib/sse';
import { api, ApiError } from '$lib/api';
import type { MeContextDto } from '$lib/types';
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
import { setRole } from '$lib/role-store';
import { loadEventConfig, commentsEnabled } from '$lib/event-config-store';
import { isBanned } from '$lib/ban-store';
let { children } = $props();
let unsubs: Array<() => void> = [];
// Slim progress bar: ratio of completed items to total, shown while processing.
let progressPct = $derived.by(() => {
const total = $queueItems.length;
if (total === 0) return 0;
const done = $queueItems.filter((i) => i.status === 'done').length;
return Math.round((done / total) * 100);
});
// Rate-limit countdown, mirrored from UploadQueue.svelte. That component is mounted ONLY on
// /upload, but the composer sends the guest straight to /feed after staging — which is where
// they invariably are when the 429 lands. The documented "Wird in Xs automatisch fortgesetzt"
// reassurance was therefore unreachable in the exact situation it exists for: all the guest
// saw was a stuck badge and a queue that appeared to have died.
let rateLimitCountdown = $state(0);
$effect(() => {
const retryAt = $rateLimitRetryAt;
if (!retryAt) {
rateLimitCountdown = 0;
return;
}
rateLimitCountdown = Math.ceil((retryAt - Date.now()) / 1000);
const interval = setInterval(() => {
rateLimitCountdown = Math.ceil((retryAt - Date.now()) / 1000);
if (rateLimitCountdown <= 0) clearInterval(interval);
}, 1000);
return () => clearInterval(interval);
});
onMount(async () => {
// With `ssr = false` the server ships an empty shell; `app.html` paints a boot
// spinner to cover the JS-load gap. The app has now mounted and painted, so drop it.
document.getElementById('app-boot')?.remove();
initAuth();
// Hooks up the appliedTheme → <html class="dark"> sync. Must run early so the
// first paint after hydration matches the saved preference.
initTheme();
// Load the public event config (colour theme + comments flag) and apply the
// palette. Applies for authed and pre-auth (/join) alike; the app.html boot script
// already painted the cached palette, this reconciles it with the server.
void loadEventConfig();
// Hydrate cross-cutting stores once on boot if the user is already authenticated.
// Page-level mounts will refresh again as needed.
if (getToken()) {
// Rehydrate the persisted upload queue from IndexedDB on EVERY boot, not just
// when /upload happens to mount. `queueItems` is a module-level store that starts
// empty, and the drain loop reads only that store — so without this, a guest whose
// PWA is evicted mid-upload (iOS does this aggressively) and who reopens onto /feed
// has pending blobs sitting in IndexedDB that nothing ever reads. The badge shows
// 0, the photos never upload, and there is no symptom to act on. This is also what
// arms the other resume paths below (`online`, `event-opened`, `feed-delta`), all
// of which call processQueue() against the same store.
void loadQueue();
try {
const ctx = await api.get<MeContextDto>('/me/context');
privacyNote.set(ctx.privacy_note);
// The live role — the JWT claim is frozen for the token's lifetime, so a
// promotion/demotion only reaches the UI through this. See role-store.ts.
setRole(ctx.role);
eventState.set({
uploadsLocked: ctx.uploads_locked,
galleryReleased: ctx.gallery_released
});
isBanned.set(ctx.is_banned);
// Now that we know the AUTHORITATIVE state, release anything the queue parked
// waiting on a host action that has already happened. The live `user-shown` /
// `event-opened` events only reach a tab that was open when the host acted, and
// the usual sequence is the other way round: the guest closes the app, the host
// lifts the ban or reopens uploads later. Without this the photo stays parked
// forever, which is exactly the "my pictures never sent" the host gets asked about.
void releaseResolvedParks({
banned: ctx.is_banned,
uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released
});
} catch (err) {
// Cross-cutting hydration on boot — failure is non-fatal; users without
// a session land on /join anyway, and the per-page mount will retry.
//
// But ONE consequence is not recoverable by a per-page mount: the park release
// above. A parked photo's other two release paths are the live `user-shown` /
// `event-opened` SSE events, and the routes that open a stream are /feed, /diashow,
// /export, /host and /admin — NOT /upload, which is exactly where the toast sends
// the guest to watch their queue. So on venue wifi, the condition this branch
// exists for, one failed request could strand the photo for the whole session with
// the queue row still reading "Du bist gesperrt.". Retry once, briefly.
//
// NOT on a 401. `api.get` already answered that one by calling `clearAuth()` and
// redirecting to /join, so there is no session left to hydrate and a second attempt
// can only fire a SECOND redirect — two seconds later, by which time the guest may
// have navigated somewhere else. Retry the transient case this exists for (offline,
// 5xx, a dropped request) and nothing else.
//
// A guard, not an early `return`: everything below this block — `refreshQuota`
// and, outside it, every SSE listener registration — still has to run.
const worthRetrying = !(err instanceof ApiError && err.status === 401);
if (worthRetrying) {
// DETACHED, not awaited. Everything below — including every SSE listener
// registered outside this block — used to sit behind it, and the worst case is
// a 20 s request timeout + 2 s backoff + a second 20 s timeout: ~42 s during
// which `pin-reset`, `user-hidden`/`user-shown`, `event-closed`/`event-opened`
// and `event-updated` are dispatched to an empty handler list. On `main` the
// exposure was one timeout; awaiting the retry doubled it, on exactly the wifi
// the retry exists for.
//
// Five of the six self-heal (`/feed`'s mount re-reads them, and the upload
// queue binds `event-opened`/`user-shown` at module scope, so parked photos
// still release). `pin-reset` does NOT: nothing else clears the cached
// plaintext PIN, so a missed one leaves a dead PIN displayed in "Mein Konto"
// and pre-filling /recover.
//
// Detaching costs nothing — the retry only writes stores and releases parks,
// and no code below reads its result.
void (async () => {
try {
await new Promise((r) => setTimeout(r, 2000));
const ctx = await api.get<MeContextDto>('/me/context');
isBanned.set(ctx.is_banned);
eventState.set({
uploadsLocked: ctx.uploads_locked,
galleryReleased: ctx.gallery_released
});
void releaseResolvedParks({
banned: ctx.is_banned,
uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released
});
} catch {
// Still down. The "Erneut" button on the parked row remains the way back.
}
})();
}
}
void refreshQuota();
}
// Global pin-reset listener — clears the now-invalid plaintext PIN from
// localStorage no matter which route the user is currently on. The reactive
// `currentPin` store carries the change into any page that reads it (My
// Account in particular).
unsubs.push(
// Server contract: `data` is a JSON string of the shape `{ user_id: UUID }`.
// We clear the cached PIN only for our own user; admin resets for other guests
// arrive on the same channel but aren't ours to act on.
onSseEvent('pin-reset', (data) => {
try {
const payload = JSON.parse(data) as { user_id: string };
if (payload.user_id === getUserId()) clearPin();
} catch {
// Malformed payload — discard; nothing actionable for the user.
}
}),
// A host banned someone. Same contract as `pin-reset`: `data` is a JSON string of
// `{ user_id: UUID }`, broadcast to everyone (it also evicts the banned user's cards
// from every feed), so only OUR id means us. Without this, `isBanned` was seeded once
// on boot and never moved — a guest banned mid-party kept the full UI and learned
// about it one 403 toast at a time, which reads as the app being broken.
onSseEvent('user-hidden', (data) => {
try {
const payload = JSON.parse(data) as { user_id: string };
if (payload.user_id === getUserId()) isBanned.set(true);
} catch {
// Malformed payload — discard; nothing actionable for the user.
}
}),
// And the mirror. This used to be one-way ("an unban has no SSE, and the next
// `/me/context` clears it") because `unban_user` broadcast nothing at all — so a
// guest whose ban was lifted kept the banned UI until they happened to reload,
// which for a PWA with no URL bar is not a thing they can easily do.
onSseEvent('user-shown', (data) => {
try {
const payload = JSON.parse(data) as { user_id: string };
if (payload.user_id === getUserId()) isBanned.set(false);
} 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.
// `event-closed` fires for BOTH a plain lock and a gallery release (release ⇒ lock),
// which markClosed can't tell apart — follow it with a server refresh so a release
// correctly flips `galleryReleased` too (else the composer briefly mislabels it as a
// plain lock until the next navigation).
onSseEvent('event-closed', () => {
markClosed();
void refreshEventState();
}),
onSseEvent('event-opened', () => markOpened()),
// A host changed the theme (or privacy note) — re-fetch the public config so the
// palette updates live on every open client, not just after a reload.
onSseEvent('event-updated', () => void loadEventConfig())
);
});
onDestroy(() => {
for (const unsub of unsubs) unsub();
});
</script>
<!-- NO `<svelte:head><link rel="icon">` here. There used to be one, pointing at
`$lib/assets/favicon.svg` — the orange Svelte logo from the project skeleton. Vite inlined it
as a data URI into the layout bundle and this block applied it AFTER hydration, so it beat the
branded `<link rel="icon">` in app.html and the tab showed the framework's logo on every page.
app.html is now the single source for the icon; the skeleton asset is deleted. -->
{@render children()}
<!-- Slim upload progress bar — sits just above the bottom nav -->
{#if $isProcessing && $isAuthenticated && $showBottomNav}
<div
class="fixed z-30 h-0.5 bg-gray-200 transition-all"
style="bottom: calc(3.5rem + env(safe-area-inset-bottom)); left: 0; right: 0"
>
<div class="h-full bg-blue-500 transition-all duration-500" style="width: {progressPct}%"></div>
</div>
{/if}
<!-- Rate-limit countdown, rendered next to the progress bar for the same reason: the queue's
own banner lives on /upload, which is not where the guest is standing when the 429 hits.
Suppressed while the bottom nav is (i.e. on /upload), where UploadQueue already shows it
in place and this would duplicate it over the sticky submit bar — and for a banned guest,
whose read-only banner occupies the same slot and is the more relevant message. -->
{#if $rateLimitRetryAt && rateLimitCountdown > 0 && $isAuthenticated && $showBottomNav && !$isBanned}
<div
role="status"
class="fixed inset-x-0 z-40 mx-auto max-w-2xl px-4 pb-2"
style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
>
<div
class="rounded-xl bg-amber-50 px-4 py-2 text-center text-sm text-amber-800 shadow-lg ring-1 ring-amber-200 dark:bg-amber-950/90 dark:text-amber-300 dark:ring-amber-900"
>
Upload-Limit erreicht. Wird in {rateLimitCountdown} Sek. automatisch fortgesetzt.
</div>
</div>
{/if}
<!-- Read-only notice for a banned guest. Rendered in the layout so it follows them across
every route, and above the bottom nav so it is not hidden behind it. A ban blocks every
write server-side but leaves the feed and the keepsake readable, so the guest needs to be
told once — otherwise the upload, like and delete controls all look available and answer
with a 403 toast each time, which reads as the app being broken.
The offset must be computed, not the fixed `bottom-16` it used to be: BottomNav is h-14
(3.5rem) PLUS env(safe-area-inset-bottom), so on a notched iPhone the nav is ~90px tall and
paints over the lower part of this banner (same z-40, later in the DOM). And it only renders
alongside the nav — on /upload the nav is suppressed and this would float over the sticky
submit bar; a banned guest can no longer open the composer anyway (see UploadSheet). -->
{#if $isBanned && $isAuthenticated && $showBottomNav}
<div
role="status"
class="fixed inset-x-0 z-40 mx-auto max-w-2xl px-4 pb-2"
style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
>
<div
class="rounded-xl bg-amber-50 px-4 py-3 text-center shadow-lg ring-1 ring-amber-200 dark:bg-amber-950/90 dark:ring-amber-900"
>
<p class="text-sm font-semibold text-amber-800 dark:text-amber-300">Nur-Lese-Modus</p>
<p class="mt-0.5 text-xs text-amber-700 dark:text-amber-400">
Du kannst alle Fotos ansehen und die Galerie später herunterladen. Hochladen, Liken{$commentsEnabled
? ' und Kommentieren'
: ''} sind für dich deaktiviert.
</p>
</div>
</div>
{/if}
<!-- UploadSheet is always mounted for smooth enter/exit animation -->
<UploadSheet />
{#if $showBottomNav && $isAuthenticated}
<BottomNav />
{/if}
<Toaster />