fix(ui): make the dashboards agree with each other and with what the code does

Host and admin implement the same four operations with independently written copy, and
admin was stale or wrong in every case. Its release button was always enabled and always
read "Galerie freigeben", so a second tap returned a 409; it showed no release state, no
keepsake progress, no failure reason, no rebuild, and never refreshed after releasing.
It now matches the host page.

Both dashboards subscribed to SSE and never opened the connection — `onSseEvent` only
registers a handler. Every subscription was inert, so the keepsake progress bar sat
frozen after a release and PIN requests appeared only on a manual refresh. It happened
to work when arriving straight from /feed, which connects, and /feed disconnects on
destroy, so navigating to the dashboard killed it again.

The unban confirm named neither of the two things a host most needs to know: unbanning
also restores ALL of that guest's previously hidden photos to the gallery, diashow and
export, and it retires and rebuilds a released keepsake, during which every guest's
download is briefly unavailable. The ban modal warns that uploads vanish; nothing said
they come back. Both now do, gated on the gallery actually being released.

"Event verlassen" implied the account was being deleted, then the dialog said the guest
could log back in. It calls `DELETE /session` — this device only, nothing deleted — so
it is "Abmelden" now. Gallery release now states it locks uploads and is reversible; PIN
reset states the guest is signed out on all devices.

The keepsake download failed silently: nothing inspected the iframe result and the
ticket POST always succeeded, so an over-limit tap did nothing at all. It now surfaces
the (newly visible) 429 and confirms the download started. `/export` rendered "Export
noch nicht verfügbar / Schau nach der Veranstaltung noch einmal vorbei" when the status
request had merely FAILED — telling a guest to come back after an event that already
happened. Both dashboards' error states gained a retry, which a host on a PWA with no
URL bar otherwise has no way to reach.

Modals were centred with no max-height, so on a short viewport the join PIN dialog
clipped equally top and bottom — potentially putting "Weiter zur Galerie" off-screen at
the moment a first-time guest must proceed. The ten moderation buttons were ~28px tall
side by side, on the screen where a mis-tap bans the wrong guest; they are 44px now.
Six German quotation marks paired the opening „ with an ASCII straight quote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Fabian Hamm (Privat)
2026-08-03 18:36:57 +02:00
parent 89ca819529
commit 2b1500e624
9 changed files with 465 additions and 54 deletions

View File

@@ -54,8 +54,12 @@
disabled={busy}
tabindex="-1"
></button>
<!-- Bottom-anchored, so a sheet taller than the viewport loses its TOP first: the title
(the only thing naming what you are confirming) slides off-screen while the danger
button stays. A long message plus a landscape phone is enough. Cap the height and
scroll instead; `overscroll-contain` keeps the page behind from scrolling with it. -->
<div
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white px-5 pb-10 pt-6 dark:bg-gray-900"
class="fixed inset-x-0 bottom-0 z-50 max-h-[85dvh] overflow-y-auto overscroll-contain rounded-t-2xl bg-white px-5 pb-10 pt-6 dark:bg-gray-900"
style="padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem)"
role="dialog"
aria-modal="true"

View File

@@ -49,7 +49,17 @@
use:focusTrap={{ onclose: onClose }}
use:scrollLock
>
<div class="card pointer-events-auto w-full max-w-sm p-6 shadow-xl">
<!-- The card must never grow past the viewport. `items-center` centres it, so an
over-tall card is clipped EQUALLY top and bottom — the confirm buttons at the
bottom disappear at the same moment the heading does, and there is no scrollbar
to hint that anything is missing. That is the worst possible failure for a
dialog whose only exits are its own buttons (the ban confirm, the one-time PIN).
`max-h-full` resolves against the padded fixed parent, so the 1rem inset is
preserved; `overscroll-contain` keeps a scroll gesture inside the dialog instead
of scrolling the page behind it. -->
<div
class="card pointer-events-auto max-h-full w-full max-w-sm overflow-y-auto overscroll-contain p-6 shadow-xl"
>
{@render children()}
</div>
</div>

View File

@@ -5,8 +5,7 @@
import { focusTrap } from '$lib/actions/focus-trap';
import { scrollLock } from '$lib/actions/scroll-lock';
import { vibrate } from '$lib/haptics';
const GUIDE_SEEN_KEY = 'eventsnap_guide_seen';
import { hasSeenGuide, markGuideSeen } from '$lib/onboarding';
type Step =
| { kind: 'text'; icon: string; title: string; body: string }
@@ -31,7 +30,7 @@
kind: 'text',
icon: '⬆️',
title: 'Fotos & Videos hochladen',
body: 'Tippe auf den Plus-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
body: 'Tippe auf den Kamera-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
},
{
kind: 'text',
@@ -55,8 +54,8 @@
icon: '🔑',
title: 'Deinen PIN merken!',
body:
'Du hast beim Registrieren einen 4-stelligen PIN erhalten. Speichere ihn — du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen. Er ist immer unter „Mein Konto" zu finden.' +
(hasPrivacyNote ? ' Den Datenschutzhinweis findest du ebenfalls unter „Mein Konto".' : '')
'Du hast beim Registrieren einen 4-stelligen PIN erhalten. Speichere ihn — du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen. Er ist immer unter „Mein Konto zu finden.' +
(hasPrivacyNote ? ' Den Datenschutzhinweis findest du ebenfalls unter „Mein Konto.' : '')
}
]);
@@ -74,7 +73,7 @@
{ value: 'dark', label: 'Dunkel', hint: 'Dunkler Hintergrund', icon: '🌙' }
];
if (browser && !localStorage.getItem(GUIDE_SEEN_KEY)) {
if (browser && !hasSeenGuide()) {
visible = true;
}
@@ -87,7 +86,7 @@
}
function dismiss() {
if (browser) localStorage.setItem(GUIDE_SEEN_KEY, '1');
markGuideSeen();
vibrate([0, 8, 60, 8]);
visible = false;
}
@@ -100,8 +99,12 @@
{#if visible}
<!-- Backdrop -->
<div class="fixed inset-0 z-50 flex items-end justify-center bg-black/60 sm:items-center">
<!-- Same viewport guard as Modal/ConfirmSheet: the theme step (three option rows) is the
tallest thing a first-time guest sees, and this dialog's only exits are its own
"Weiter"/"Überspringen" buttons at the very bottom. Clipping them strands the guest
on step one of a modal they cannot dismiss. -->
<div
class="w-full max-w-sm rounded-t-3xl bg-white p-6 shadow-2xl dark:bg-gray-900 sm:rounded-2xl"
class="max-h-[90dvh] w-full max-w-sm overflow-y-auto overscroll-contain rounded-t-3xl bg-white p-6 shadow-2xl dark:bg-gray-900 sm:rounded-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-title"
@@ -150,7 +153,7 @@
</p>
{:else}
<p class="mb-4 text-sm text-gray-600 dark:text-gray-300">
Du kannst die Wahl jederzeit unter „Mein Konto" ändern.
Du kannst die Wahl jederzeit unter „Mein Konto ändern.
</p>
<div class="space-y-2 text-left" role="radiogroup" aria-label="Design">
{#each THEME_OPTIONS as opt (opt.value)}

View File

@@ -537,9 +537,12 @@
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
/>
</svg>
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400"
>Event verlassen</span
>
<!-- "Abmelden", not "Event verlassen". The action is `DELETE /session` — it revokes
THIS device's session and clears the local token. Nothing is deleted: the
account, the PIN and every uploaded photo stay, and the guest returns with
their PIN. "Event verlassen" read as leaving for good / being removed, which
the confirm sheet then contradicted by saying you can come back. -->
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400">Abmelden</span>
</button>
<!-- Sign out everywhere (all devices) -->
@@ -612,10 +615,10 @@
<!-- Leave-confirm bottom sheet -->
<ConfirmSheet
open={leaveConfirmOpen}
title={leaveEverywhere ? 'Auf allen Geräten abmelden?' : 'Event verlassen?'}
title={leaveEverywhere ? 'Auf allen Geräten abmelden?' : 'Abmelden?'}
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.'}
? 'Alle deine Sitzungen (auch auf anderen Geräten) werden beendet. Deine Fotos bleiben erhalten — mit deinem Namen und PIN kannst du dich jederzeit wieder anmelden.'
: 'Du wirst nur auf diesem Gerät abgemeldet. Deine Fotos bleiben erhalten — mit deinem Namen und PIN kannst du dich jederzeit wieder anmelden.'}
confirmLabel="Abmelden"
tone="danger"
onConfirm={() => handleLogout(leaveEverywhere)}

View File

@@ -4,6 +4,7 @@
import { role as myRoleStore } from '$lib/role-store';
import { api } from '$lib/api';
import { onMount } from 'svelte';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import { toast, toastError } from '$lib/toast-store';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import Modal from '$lib/components/Modal.svelte';
@@ -36,6 +37,20 @@
completed_at: string | null;
}
// `/export/status` — the same derived view the host dashboard and the guest export page
// use (released + per-half job state). Deliberately separate from `ExportJob` above,
// which is the admin-only raw job-row listing.
interface ExportStatusJob {
status: string;
progress_pct: number;
error_message: string | null;
}
interface ExportStatusDto {
released: boolean;
zip: ExportStatusJob | null;
html: ExportStatusJob | null;
}
interface UserSummary {
id: string;
display_name: string;
@@ -238,10 +253,17 @@
themeAccent.toLowerCase() !== savedTheme.accent.toLowerCase()
);
// SSE unsubscribers for the live keepsake status, torn down on destroy.
let sseOff: Array<() => void> = [];
let destroyed = false;
// Leaving the admin page without saving must not strand an unsaved preview on the
// rest of the app — revert to the persisted theme. loadEventConfig re-applies the
// authoritative palette + cache.
onDestroy(() => {
destroyed = true;
disconnectSse();
for (const off of sseOff) off();
void loadEventConfig();
});
@@ -265,6 +287,36 @@
let error = $state<string | null>(null);
let exportJobsRefreshing = $state(false);
// ── Keepsake state (parity with the host dashboard) ─────────────────────────
// The Export tab used to offer a release button that was always enabled and always
// labelled "Galerie freigeben", with no indication of whether the release had already
// happened, how the build was going, or why it failed — so a second tap just returned
// 409 "bereits freigegeben", and a failed keepsake was invisible AND unrecoverable from
// here. Same data, same controls and the same wording as /host now.
let exportInfo = $state<ExportStatusDto | null>(null);
let rebuilding = $state(false);
let exportReady = $derived(
exportInfo?.zip?.status === 'done' && exportInfo?.html?.status === 'done'
);
// `&&`, not `||`: the keepsake needs BOTH halves, so one failing half means the whole
// thing failed. With `||` a one-half failure would sit on "wird erstellt…" forever and
// never reveal the retry.
let exportGenerating = $derived(
!!exportInfo?.released &&
!exportReady &&
exportInfo?.zip?.status !== 'failed' &&
exportInfo?.html?.status !== 'failed'
);
let exportProgress = $derived(
Math.min(exportInfo?.zip?.progress_pct ?? 0, exportInfo?.html?.progress_pct ?? 0)
);
// A disk failure usually fails both halves with the same text — take the first present
// rather than printing it twice.
let exportError = $derived(
exportInfo?.zip?.error_message ?? exportInfo?.html?.error_message ?? null
);
// Nutzer tab state
let userSearch = $state('');
let filteredUsers = $derived(
@@ -326,6 +378,27 @@
return;
}
await reload();
// The awaits above mean the component can already be destroyed by the time we get here
// (navigated away mid-load). Svelte doesn't cancel an async onMount, so without this
// guard onDestroy would have run with an empty `sseOff` and these handlers would leak
// into the module-global SSE map forever. (Same guard as the host page.)
if (destroyed) return;
// Open the stream — `onSseEvent` only registers a handler, it does not connect. Without
// this the subscriptions below never fire and the keepsake status is frozen; see the
// same fix and reasoning on the host dashboard. Idempotent.
connectSse();
// Keepsake generation moves → refresh the status line and the job list without a
// manual tap, exactly as the host dashboard does.
sseOff = [
onSseEvent('export-progress', () => void refreshExportStatus()),
onSseEvent('export-available', () => {
void refreshExportStatus();
void refreshExportJobs();
})
];
});
async function reload() {
@@ -345,12 +418,33 @@
} finally {
loading = false;
}
// Keepsake status is a secondary widget — fetched OUTSIDE the all-or-nothing block
// above (and its error swallowing) so a transient /export/status failure can't blank
// the whole dashboard. Mirrors the host page.
void refreshExportStatus();
}
/** Refetch just the keepsake status (released / live progress / failure reason). */
async function refreshExportStatus() {
try {
exportInfo = await api.get<ExportStatusDto>('/export/status');
} catch {
/* non-fatal — the next refresh or SSE tick picks it up */
}
}
async function refreshExportJobs() {
exportJobsRefreshing = true;
try {
exportJobs = await api.get<ExportJob[]>('/admin/export/jobs');
// The raw job rows and the derived status are two views of the same thing; a manual
// refresh that updated only one of them would leave the two halves of this tab
// disagreeing about whether the keepsake is ready.
await Promise.all([
(async () => {
exportJobs = await api.get<ExportJob[]>('/admin/export/jobs');
})(),
refreshExportStatus()
]);
} finally {
exportJobsRefreshing = false;
}
@@ -400,8 +494,31 @@
try {
await api.post('/host/gallery/release');
toast('Galerie wurde freigegeben. Export wird vorbereitet…', 'success');
// Without this the Export tab kept showing the pre-release world: no jobs in the
// list, the button still inviting a second (409-ing) release.
await reload();
} catch (e: unknown) {
toastError(e);
// Release stamps `export_released_at` before enqueuing the workers; if that second
// step errored the event IS released, so reconcile rather than leaving a button that
// reads "Galerie freigeben" and can now only 409.
await reload();
}
}
// The escape hatch for a failed or stale keepsake — the host dashboard has had it; without
// it here the only recovery from the admin side was reopening uploads (which retracts the
// release for every guest) or restarting the container.
async function rebuildExport() {
rebuilding = true;
try {
await api.post('/host/export/rebuild', {});
toast('Keepsake wird neu erstellt…', 'success');
await refreshExportJobs();
} catch (e: unknown) {
toastError(e);
} finally {
rebuilding = false;
}
}
@@ -417,6 +534,9 @@
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
banTarget = null;
users = await api.get<UserSummary[]>('/host/users');
// A ban on a released event retires and rebuilds the keepsake (`invalidate_and_arm`),
// so the status block above is stale the moment this returns.
void refreshExportStatus();
} catch (e: unknown) {
toastError(e);
} finally {
@@ -429,11 +549,33 @@
await api.post(`/host/users/${user.id}/unban`);
toast(`Sperre für ${user.display_name} aufgehoben.`, 'success');
users = await api.get<UserSummary[]>('/host/users');
// Same as ban: an unban rebuilds a released keepsake, so re-read the status.
void refreshExportStatus();
} catch (e: unknown) {
toastError(e);
}
}
/**
* Copy for the unban confirm — same text as the host dashboard, same reasons. Verified
* against `unban_user` (backend/src/handlers/host.rs): besides lifting the write block it
* clears `uploads_hidden` (so every photo the ban hid returns to gallery, diashow and
* export) and calls `invalidate_and_arm` (so a released keepsake is retired and rebuilt,
* and guest downloads are unavailable meanwhile). The ban modal warns that uploads vanish;
* this is the sentence that says they come back.
*/
function unbanMessage(user: UserSummary): string {
const actions = $commentsEnabled ? ', liken und kommentieren' : ' und liken';
let msg =
`${user.display_name} kann danach wieder hochladen${actions}. ` +
`Alle bisher ausgeblendeten Uploads von ${user.display_name} erscheinen wieder in Galerie, Diashow und Export.`;
if (exportInfo?.released) {
msg +=
' Das bereits freigegebene Keepsake wird deshalb neu erstellt — währenddessen können Gäste es kurz nicht herunterladen.';
}
return msg;
}
async function promoteToHost(user: UserSummary) {
try {
await api.patch(`/host/users/${user.id}/role`, { role: 'host' });
@@ -547,7 +689,8 @@
open={pinResetTarget !== null}
title="PIN zurücksetzen"
message={pinResetTarget
? `Eine neue PIN für ${pinResetTarget.display_name} wird erzeugt. Die alte PIN funktioniert dann nicht mehr.`
? // Same fix as the host page: the reset revokes every session, so say so.
`Eine neue PIN für ${pinResetTarget.display_name} wird erzeugt. Die alte PIN funktioniert dann nicht mehr, und ${pinResetTarget.display_name} wird auf allen Geräten abgemeldet und muss sich mit der neuen PIN neu anmelden.`
: ''}
confirmLabel={pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
tone="danger"
@@ -591,8 +734,24 @@
Benutzer sperren
</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
<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“.
<!-- Was: "…und die Sitzung wird beendet." That is the opposite of what happens — the
ban is deliberately READ-ONLY (backend/src/handlers/host.rs: sessions are not
revoked, and the export ticket is intentionally not gated on is_banned). An
admin told the user is logged out stops watching, while the guest keeps
browsing and still downloads the keepsake. Text below matches the host page,
which had it right. -->
<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus
Galerie, Diashow und Export, und Hochladen, Liken{$commentsEnabled ? ' und Kommentieren' : ''} werden
blockiert. Der Lesezugriff (Feed ansehen, Keepsake herunterladen) bleibt bestehen. Rückgängig machbar
über „Entsperren“.
{#if exportInfo?.released}
<!-- Mirror of the unban sentence: `ban_user` runs `invalidate_and_arm` in the same
transaction, so on a released event the keepsake is retired and rebuilt and every
guest's download 404s until that finishes. Only shown when there is a released
keepsake to lose. -->
Da die Galerie bereits freigegeben ist, wird das Keepsake ohne diese Uploads neu erstellt — währenddessen
können Gäste es kurz nicht herunterladen.
{/if}
</p>
<div class="flex gap-2">
<button onclick={() => (banTarget = null)} class="btn btn-secondary btn-sm flex-1"
@@ -647,11 +806,17 @@
{#if loading}
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
{:else if error}
<!-- A retry, not just a verdict: `reload()` fails for transient reasons (wifi blip),
and without this the only way out is a browser reload — not obvious, and not even
reachable, in an installed PWA with no URL bar. -->
<div
role="alert"
class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
>
{error}
<p>{error}</p>
<button onclick={() => void reload()} class="btn btn-secondary btn-sm mt-3">
Erneut laden
</button>
</div>
{:else}
<!-- ── Stats tab ────────────────────────────────────────────────── -->
@@ -942,7 +1107,9 @@
<!-- ── Export tab ───────────────────────────────────────────────── -->
{:else if activeTab === 'export'}
<div class="space-y-3">
<!-- Gallery release -->
<!-- Gallery release — deliberately the same controls, gating and wording as the
host dashboard's Event-Einstellungen block. Two independently written UIs for
one operation is how they drifted apart in the first place. -->
<div class="card p-5">
<h3 class="mb-3 font-semibold text-gray-900 dark:text-gray-100">Galerie</h3>
<button
@@ -950,15 +1117,96 @@
(confirmAction = {
title: 'Galerie freigeben?',
message:
'Gäste können dann alle Fotos herunterladen. Das kann nicht rückgängig gemacht werden.',
// See the identical fix on the host page: the release is reversible via
// "Uploads wieder öffnen", and it locks uploads at the same time.
'Uploads werden dabei gesperrt, und Gäste können alle Fotos herunterladen. Rückgängig machbar: „Uploads wieder öffnen“ zieht die Freigabe zurück.',
confirmLabel: 'Freigeben',
tone: 'danger',
run: releaseGallery
})}
disabled={exportInfo?.released}
class="btn btn-primary btn-sm"
>
Galerie freigeben
{exportInfo?.released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
</button>
<!-- Live keepsake status. After release the ZIP/HTML still take minutes to build
and guest downloads 404 until they're done, so an admin who can't see this
announces "released!" while every download still fails. -->
{#if exportInfo?.released}
<div class="mt-3 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-gray-800/60">
{#if exportGenerating}
<p class="font-medium text-amber-700 dark:text-amber-300">
Keepsake wird erstellt… {exportProgress}%
</p>
<div
class="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"
>
<div
class="h-full rounded-full bg-amber-500 transition-all"
style="width: {exportProgress}%"
></div>
</div>
{:else if exportReady}
<p class="flex items-center justify-between gap-2">
<span class="font-medium text-green-700 dark:text-green-300"
>Keepsake ist bereit.</span
>
<a href="/export" class="font-medium text-blue-600 underline dark:text-blue-400"
>Herunterladen</a
>
</p>
{:else}
<p class="text-red-700 dark:text-red-300">Keepsake-Erstellung fehlgeschlagen.</p>
<!-- The reason, not just the verdict: the common failure is "not enough disk",
and retrying without freeing space fails identically forever. The backend
already wrote a message naming the numbers. -->
{#if exportError}
<p class="mt-1 text-red-700/80 dark:text-red-300/80">{exportError}</p>
{/if}
{/if}
<!-- ONE button, mounted in every state — deliberately OUTSIDE the branches
above. Those branches are SSE-driven, and `rebuildExport` itself makes the
backend broadcast `export-progress` at 0% immediately: a button living
inside a branch would UNMOUNT ITSELF between mousedown and mouseup, and
Chromium then fires no `click` at all. So the button's existence is
invariant; only its label and `disabled` change. (Same reasoning as the
host page — see the longer note there.) -->
<button
onclick={() => {
// Rebuilding a READY keepsake is disruptive (guests tapping Herunterladen
// get nothing until it finishes), so it asks first. A FAILED keepsake has
// nothing to lose and retries immediately.
if (exportReady) {
confirmAction = {
title: 'Keepsake neu erstellen?',
message:
'Das Keepsake wird aus dem aktuellen Stand der Galerie neu erzeugt. ' +
'Während der Erstellung können Gäste es nicht herunterladen.',
confirmLabel: 'Neu erstellen',
tone: 'danger',
run: rebuildExport
};
} else {
void rebuildExport();
}
}}
disabled={rebuilding || exportGenerating}
data-testid="export-rebuild"
class="mt-2 inline-flex min-h-11 items-center rounded-lg px-3 py-1.5 text-xs font-medium transition disabled:opacity-50
{exportReady
? 'text-gray-500 underline dark:text-gray-400'
: 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400'}"
>
{rebuilding
? 'Wird gestartet…'
: exportReady
? 'Neu erstellen'
: 'Erneut versuchen'}
</button>
</div>
{/if}
</div>
<!-- Export jobs -->
@@ -1075,19 +1323,21 @@
)}
</p>
</div>
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
<!-- 44 px targets and a wider gap, not the ~28 px/6 px this had: these buttons
sit shoulder to shoulder on a phone and a mis-tap here bans the wrong guest. -->
<div class="flex shrink-0 flex-wrap justify-end gap-2">
{#if user.role !== 'admin'}
{#if user.is_banned}
<button
onclick={() =>
(confirmAction = {
title: 'Sperre aufheben?',
message: `${user.display_name} kann danach wieder hochladen${$commentsEnabled ? ', liken und kommentieren' : ' und liken'}.`,
message: unbanMessage(user),
confirmLabel: 'Entsperren',
tone: 'default',
run: () => unban(user)
})}
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
class="inline-flex min-h-11 items-center rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
>
Entsperren
</button>
@@ -1102,7 +1352,7 @@
tone: 'default',
run: () => promoteToHost(user)
})}
class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60"
class="inline-flex min-h-11 items-center rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60"
>
Host
</button>
@@ -1117,7 +1367,7 @@
tone: 'danger',
run: () => demoteToGuest(user)
})}
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
class="inline-flex min-h-11 items-center rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
>
Degradieren
</button>
@@ -1125,14 +1375,14 @@
{#if canResetPinFor(user)}
<button
onclick={() => askResetPin(user)}
class="rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60"
class="inline-flex min-h-11 items-center rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60"
>
PIN zurücksetzen
</button>
{/if}
<button
onclick={() => openBanModal(user)}
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60"
class="inline-flex min-h-11 items-center rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60"
>
Sperren
</button>

View File

@@ -4,7 +4,7 @@
import { api } from '$lib/api';
import { onMount, onDestroy } from 'svelte';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import { toastError } from '$lib/toast-store';
import { toast, toastError } from '$lib/toast-store';
import { focusTrap } from '$lib/actions/focus-trap';
import IconButton from '$lib/components/IconButton.svelte';
import { commentsEnabled } from '$lib/event-config-store';
@@ -25,6 +25,14 @@
let status = $state<ExportStatus | null>(null);
let showHtmlGuide = $state(false);
let loading = $state(true);
// A failed status fetch used to leave `status` null, which rendered "Export noch nicht
// verfügbar / Schau nach der Veranstaltung noch einmal vorbei" — telling a guest whose
// request timed out to come back after an event that has already happened. Distinguish
// "we don't know" from "not released yet".
let statusError = $state(false);
// Guards against a double-tap minting two single-use tickets (the second would be spent
// against a rate limit for a download the guest never sees).
let downloading = $state(false);
let unsubscribers: (() => void)[] = [];
@@ -60,14 +68,23 @@
async function loadStatus() {
try {
status = await api.get<ExportStatus>('/export/status');
statusError = false;
} catch {
// Background poll triggered by SSE silent. The visible empty/loading state
// will reflect the failure; the next event will retry.
// Background polls triggered by SSE stay silent — but only while we still have a
// status to show. With nothing on screen the failure has to be visible, or the
// page lies about the state of the one thing the guest came here for.
if (!status) statusError = true;
} finally {
loading = false;
}
}
async function retryStatus() {
statusError = false;
loading = true;
await loadStatus();
}
function statusText(job: JobStatus): string {
switch (job.status) {
case 'locked':
@@ -99,14 +116,28 @@
let downloadFrame: HTMLIFrameElement;
async function downloadFile(endpoint: string) {
if (downloading) return;
downloading = true;
try {
const { ticket } = await api.post<{ ticket: string }>('/export/ticket');
downloadFrame.src = `${endpoint}?ticket=${encodeURIComponent(ticket)}`;
// An iframe download produces NO visible change: no spinner, no navigation, and
// on mobile often no browser chrome either. Without a word here the guest cannot
// tell a download that started from a button that did nothing — and the archive
// can take minutes, so they tap again, and again.
toast('Download gestartet schau in deine Downloads.', 'success');
// If the archive was retired between the page render and this click, the download
// silently does nothing — so re-sync the status to flip the UI to "generating".
setTimeout(() => void loadStatus(), 1500);
} catch (e) {
// The export rate limit is enforced at ticket-mint, so THIS is the only place a
// guest can be told they've hit the daily download cap. Swallowing it (as the old
// code did by never inspecting anything) meant every tap after the third did
// literally nothing, forever, at the emotional payoff of the whole product, with
// no operator anywhere to explain why.
toastError(e);
} finally {
downloading = false;
}
}
@@ -200,6 +231,32 @@
<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 statusError}
<!-- Ahead of the "noch nicht verfügbar" branch: both leave `status` null, but only
one of them is true, and guessing wrong sends the guest away for good. -->
<div class="card p-6 text-center" data-testid="export-status-error">
<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"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
/>
</svg>
<p class="font-medium text-gray-700 dark:text-gray-300">Status nicht abrufbar</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Die Verbindung hat nicht geklappt. Versuch es gleich noch einmal.
</p>
<button onclick={() => void retryStatus()} class="btn btn-primary btn-sm mt-4">
Erneut versuchen
</button>
</div>
{:else if !status?.released}
<div class="card p-6 text-center">
<svg

View File

@@ -5,7 +5,7 @@
import { api } from '$lib/api';
import type { MeContextDto } from '$lib/types';
import { onMount, onDestroy } from 'svelte';
import { onSseEvent } from '$lib/sse';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import { toast, toastError } from '$lib/toast-store';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import Modal from '$lib/components/Modal.svelte';
@@ -172,6 +172,13 @@
// these handlers into the module-global SSE map forever.
if (destroyed) return;
// Open the stream. `onSseEvent` only REGISTERS a handler — it does not connect, and this
// page never did, so every subscription below was inert: the keepsake progress bar sat
// frozen after a release and PIN requests only appeared on a manual refresh. (It happened
// to work when arriving straight from /feed, which connects — and /feed disconnects on
// destroy, so navigating here killed it again.) Idempotent, so overlapping callers are fine.
connectSse();
// Live updates so the dashboard doesn't need a manual reload:
// - pin-reset-requested: a guest just asked for a reset → refresh the badge/list.
// - pin-reset: some host resolved a reset → drop it from the list (two-host race:
@@ -188,6 +195,7 @@
let destroyed = false;
onDestroy(() => {
destroyed = true;
disconnectSse();
for (const off of sseOff) off();
});
@@ -351,6 +359,28 @@
}
}
/**
* Copy for the unban confirm. The old one-liner mentioned only that the guest may post
* again, which is the least of it. Verified against `unban_user` (backend/src/handlers/host.rs):
* - it clears `uploads_hidden`, so every photo the ban hid comes BACK — to the gallery, the
* diashow and the export. The ban modal warns they vanish; nothing said they return, and
* that is the half that surprises a host in front of a room full of guests.
* - it runs `invalidate_and_arm`, so a released keepsake is retired and rebuilt, and every
* guest's download is unavailable while that runs.
* Comments are an event-level toggle, so the verb list branches like the ban copy does.
*/
function unbanMessage(user: UserSummary): string {
const actions = $commentsEnabled ? ', liken und kommentieren' : ' und liken';
let msg =
`${user.display_name} kann danach wieder hochladen${actions}. ` +
`Alle bisher ausgeblendeten Uploads von ${user.display_name} erscheinen wieder in Galerie, Diashow und Export.`;
if (event?.export_released) {
msg +=
' Das bereits freigegebene Keepsake wird deshalb neu erstellt — währenddessen können Gäste es kurz nicht herunterladen.';
}
return msg;
}
async function promoteToHost(user: UserSummary) {
try {
await api.patch(`/host/users/${user.id}/role`, { role: 'host' });
@@ -422,7 +452,11 @@
open={pinResetTarget !== null}
title="PIN zurücksetzen"
message={pinResetTarget
? `Eine neue PIN für ${pinResetTarget.display_name} wird erzeugt. Die alte PIN funktioniert dann nicht mehr.`
? // The reset also revokes every session (`Session::delete_all_for_user`), so the guest
// is signed out on their phone mid-event and must re-enter name + new PIN. The old
// wording described a credential rotation and omitted the logout entirely — the
// person pressing this needs to know they are about to interrupt someone.
`Eine neue PIN für ${pinResetTarget.display_name} wird erzeugt. Die alte PIN funktioniert dann nicht mehr, und ${pinResetTarget.display_name} wird auf allen Geräten abgemeldet und muss sich mit der neuen PIN neu anmelden.`
: ''}
confirmLabel={pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
tone="danger"
@@ -468,6 +502,14 @@
Galerie, Diashow und Export, und Hochladen, Liken{$commentsEnabled ? ' und Kommentieren' : ''} werden
blockiert. Der Lesezugriff (Feed ansehen, Keepsake herunterladen) bleibt bestehen. Rückgängig machbar
über „Entsperren“.
{#if event?.export_released}
<!-- The mirror of the unban sentence. `ban_user` runs `invalidate_and_arm` in the same
transaction, so a ban on a RELEASED event retires the current keepsake and starts a
rebuild — every guest's download 404s for the couple of minutes that takes. Only say
so when there is a released keepsake to lose; before release it is noise. -->
Da die Galerie bereits freigegeben ist, wird das Keepsake ohne diese Uploads neu erstellt — währenddessen
können Gäste es kurz nicht herunterladen.
{/if}
</p>
<div class="flex gap-2">
<button onclick={() => (banTarget = null)} class="btn btn-secondary flex-1">
@@ -508,10 +550,18 @@
{#if loading}
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
{:else if error}
<!-- A retry, not just a verdict. `reload()` fails for entirely transient reasons (the
venue's wifi dropping for a second), and without a button in here the only way out
is a browser reload — which a host running this as an installed PWA has no URL bar
to reach for, and may not think of at all. -->
<div
role="alert"
class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300"
>
{error}
<p>{error}</p>
<button onclick={() => void reload()} class="btn btn-secondary btn-sm mt-3">
Erneut laden
</button>
</div>
{:else if event}
<!-- ── Speicherwarnung ─────────────────────────────────────────────
@@ -691,7 +741,12 @@
(confirmAction = {
title: 'Galerie freigeben?',
message:
'Gäste können dann alle Fotos herunterladen. Das kann nicht rückgängig gemacht werden.',
// Was: "Das kann nicht rückgängig gemacht werden." It is reversible — `open_event`
// clears `export_released_at`, and this page offers exactly that below
// ("Galerie-Freigabe zurücknehmen?"). The old wording also omitted that
// releasing LOCKS uploads in the same statement, so a host releasing early
// believed the party's uploads were shut for good and never reopened them.
'Uploads werden dabei gesperrt, und Gäste können alle Fotos herunterladen. Rückgängig machbar: „Uploads wieder öffnen“ zieht die Freigabe zurück.',
confirmLabel: 'Freigeben',
tone: 'danger',
run: releaseGallery
@@ -725,8 +780,11 @@
<span class="font-medium text-green-700 dark:text-green-300"
>Keepsake ist bereit.</span
>
<!-- "Herunterladen" everywhere, never "Download" — the guests this app is for are
German-speaking and not necessarily technical, and the same action must not
have two names across the export page and the dashboards. -->
<a href="/export" class="font-medium text-blue-600 underline dark:text-blue-400"
>Zum Download</a
>Herunterladen</a
>
</p>
{:else}
@@ -809,10 +867,17 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
<!-- Open state SCROLLS rather than relying on a pixel ceiling. `max-h-[9999px]`
with `overflow-hidden` silently swallowed everything past ~9999px: guest rows
wrap to ~90-110px on a phone, so a ~100-guest event sits right on that
boundary, and the guests past it could not be banned, PIN-reset or promoted —
with nothing on screen to say they existed (the "Teilnehmer" tile still
counted them). A bounded max-height plus scroll keeps the collapse animation
and makes the list complete at any guest count. -->
<div
class="overflow-hidden transition-[max-height] duration-300 {usersOpen
? 'max-h-[9999px]'
: 'max-h-0'}"
class="transition-[max-height] duration-300 {usersOpen
? 'max-h-[70vh] overflow-y-auto overscroll-contain'
: 'max-h-0 overflow-hidden'}"
>
<div class="border-t border-gray-100 dark:border-gray-700">
<!-- Search -->
@@ -869,7 +934,11 @@
)}
</p>
</div>
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
<!-- 44 px targets and a wider gap, not the ~28 px/6 px this had. Five buttons
sit shoulder to shoulder on a phone here, and a mis-tap bans the wrong
guest in front of a room — this is the screen where the fat-finger margin
has to be generous. -->
<div class="flex shrink-0 flex-wrap justify-end gap-2">
{#if user.role !== 'admin'}
{#if user.is_banned}
<!-- Only show Entsperren to someone allowed to act on this user (a plain
@@ -879,12 +948,12 @@
onclick={() =>
(confirmAction = {
title: 'Sperre aufheben?',
message: `${user.display_name} kann danach wieder hochladen${$commentsEnabled ? ', liken und kommentieren' : ' und liken'}.`,
message: unbanMessage(user),
confirmLabel: 'Entsperren',
tone: 'default',
run: () => unban(user)
})}
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
class="inline-flex min-h-11 items-center rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
>
Entsperren
</button>
@@ -904,7 +973,7 @@
tone: 'default',
run: () => promoteToHost(user)
})}
class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60"
class="inline-flex min-h-11 items-center rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60"
>
Host
</button>
@@ -921,7 +990,7 @@
tone: 'danger',
run: () => demoteToGuest(user)
})}
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
class="inline-flex min-h-11 items-center rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
>
Degradieren
</button>
@@ -929,7 +998,7 @@
{#if canModerate(user)}
<button
onclick={() => askResetPin(user)}
class="rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60"
class="inline-flex min-h-11 items-center rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60"
>
PIN zurücksetzen
</button>
@@ -937,7 +1006,7 @@
{#if canModerate(user)}
<button
onclick={() => openBanModal(user)}
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60"
class="inline-flex min-h-11 items-center rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60"
>
Sperren
</button>

View File

@@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth } from '$lib/auth';
import { markGuideSeen } from '$lib/onboarding';
import { focusTrap } from '$lib/actions/focus-trap';
// Show which event the guest is joining (USER_JOURNEYS §1). Public, pre-auth.
@@ -72,6 +73,9 @@
pin: recoveryPin.trim()
});
setAuth(res.jwt, recoveryPin.trim(), res.user_id, takenName);
// Same as the dedicated /recover page: this is an EXISTING guest reclaiming their
// account, not a first-timer, so don't replay the onboarding guide at them.
markGuideSeen();
goto('/feed');
} catch (e) {
if (e instanceof ApiError) {
@@ -182,12 +186,12 @@
class="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800/60 dark:bg-amber-950/30"
>
<p class="font-semibold text-amber-900 dark:text-amber-200">
{takenName}" ist bereits vergeben.
{takenName} ist bereits vergeben.
</p>
<p class="mt-1 text-sm text-amber-800 dark:text-amber-300/90">
Wähle einen anderen Namen, z.&nbsp;B. einen Spitznamen oder füge deinen Nachnamen hinzu
(„{takenName}
M." oder „{takenName} aus Berlin").
M. oder „{takenName} aus Berlin).
</p>
</div>
@@ -322,11 +326,16 @@
{#if showPinModal}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
data-testid="pin-modal"
>
<!-- Vertically centred, so anything taller than the viewport is clipped equally at both
ends — and this card is ~320 px, which a phone in landscape does not have. The
casualty would be "Weiter zur Galerie" at the very moment a first-time guest has to
get past it, with no visible scrollbar to suggest there is more. Cap the height and
scroll; `overscroll-contain` stops the join page behind from scrolling instead. -->
<div
class="card w-full max-w-sm p-6 shadow-xl"
class="card max-h-full w-full max-w-sm overflow-y-auto overscroll-contain p-6 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="pin-modal-title"

View File

@@ -2,6 +2,7 @@
import { goto, afterNavigate } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth, getPin, getToken, clearPin } from '$lib/auth';
import { markGuideSeen } from '$lib/onboarding';
import { browser } from '$app/environment';
import IconButton from '$lib/components/IconButton.svelte';
@@ -67,6 +68,11 @@
}>('/recover', { display_name: displayName.trim(), pin: pin.trim() });
setAuth(res.jwt, pin.trim(), res.user_id, displayName.trim());
// Recovering proves this guest already has an account, so they have already been
// through onboarding — on their ORIGINAL device, whose localStorage this one does
// not share. Without this, every guest who switches phone, clears site data or
// opens the event in a second browser gets the full first-run guide again.
markGuideSeen();
// Surface a welcome-back toast on /feed after navigation. sessionStorage
// scopes the cue to the next page load so it doesn't replay on refresh.
if (browser) sessionStorage.setItem('eventsnap_just_recovered', displayName.trim());