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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user