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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. 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"
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user