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>
215 lines
7.2 KiB
Svelte
215 lines
7.2 KiB
Svelte
<script lang="ts">
|
|
import { browser } from '$app/environment';
|
|
import { privacyNote } from '$lib/privacy-note-store';
|
|
import { themePreference, type ThemePreference } from '$lib/theme-store';
|
|
import { focusTrap } from '$lib/actions/focus-trap';
|
|
import { scrollLock } from '$lib/actions/scroll-lock';
|
|
import { vibrate } from '$lib/haptics';
|
|
import { hasSeenGuide, markGuideSeen } from '$lib/onboarding';
|
|
|
|
type Step =
|
|
| { kind: 'text'; icon: string; title: string; body: string }
|
|
| { kind: 'theme'; icon: string; title: string };
|
|
|
|
let visible = $state(false);
|
|
let step = $state(0);
|
|
|
|
// The PIN step gets an extra line when the admin has set a Datenschutzhinweis. Done
|
|
// reactively so the nudge appears as soon as the note loads from `/me/context`,
|
|
// even if the user opens the onboarding before the request returns.
|
|
let hasPrivacyNote = $derived($privacyNote.trim().length > 0);
|
|
|
|
let steps: Step[] = $derived([
|
|
{
|
|
kind: 'text',
|
|
icon: '📸',
|
|
title: 'Willkommen bei EventSnap!',
|
|
body: 'Hier kannst du Fotos und Videos mit allen Gästen teilen — in Echtzeit, ganz ohne App-Store.'
|
|
},
|
|
{
|
|
kind: 'text',
|
|
icon: '⬆️',
|
|
title: 'Fotos & Videos hochladen',
|
|
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',
|
|
icon: '#️⃣',
|
|
title: 'Hashtags nutzen',
|
|
body: 'Füge in deiner Bildunterschrift #hashtags ein, um Fotos zu gruppieren — z.B. #tanz, #buffet oder #reden. Du kannst danach filtern.'
|
|
},
|
|
{
|
|
kind: 'text',
|
|
icon: '👆',
|
|
title: 'Lange tippen für mehr',
|
|
body: 'Tippe lange auf ein Bild im Feed, um zusätzliche Aktionen zu öffnen — zum Beispiel das Original anzeigen oder eigene Beiträge löschen.'
|
|
},
|
|
{
|
|
kind: 'theme',
|
|
icon: '🌗',
|
|
title: 'Helles oder dunkles Design?'
|
|
},
|
|
{
|
|
kind: 'text',
|
|
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“.' : '')
|
|
}
|
|
]);
|
|
|
|
// Derived in the script so TypeScript can narrow on `.kind` inside the template.
|
|
let currentStep = $derived(steps[step]);
|
|
|
|
const THEME_OPTIONS: Array<{
|
|
value: ThemePreference;
|
|
label: string;
|
|
hint: string;
|
|
icon: string;
|
|
}> = [
|
|
{ value: 'system', label: 'System', hint: 'Folgt der Geräteeinstellung', icon: '🖥️' },
|
|
{ value: 'light', label: 'Hell', hint: 'Heller Hintergrund', icon: '☀️' },
|
|
{ value: 'dark', label: 'Dunkel', hint: 'Dunkler Hintergrund', icon: '🌙' }
|
|
];
|
|
|
|
if (browser && !hasSeenGuide()) {
|
|
visible = true;
|
|
}
|
|
|
|
function next() {
|
|
if (step < steps.length - 1) {
|
|
step++;
|
|
} else {
|
|
dismiss();
|
|
}
|
|
}
|
|
|
|
function dismiss() {
|
|
markGuideSeen();
|
|
vibrate([0, 8, 60, 8]);
|
|
visible = false;
|
|
}
|
|
|
|
function goToStep(i: number) {
|
|
if (i >= 0 && i < steps.length) step = i;
|
|
}
|
|
</script>
|
|
|
|
{#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="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"
|
|
use:focusTrap={{ onclose: dismiss }}
|
|
use:scrollLock
|
|
>
|
|
<!-- Step indicator — tap a pip to jump back. The visible dot is small but
|
|
the touch target is padded to ~44 px so it remains tappable on mobile. -->
|
|
<div class="mb-3 flex justify-center">
|
|
{#each steps as _, i (i)}
|
|
<button
|
|
type="button"
|
|
onclick={() => goToStep(i)}
|
|
aria-label={`Schritt ${i + 1}`}
|
|
aria-current={i === step ? 'step' : undefined}
|
|
class="flex items-center justify-center p-2.5"
|
|
>
|
|
<span
|
|
class="block rounded-full transition-all {i === step
|
|
? 'h-1.5 w-6 bg-blue-600 dark:bg-blue-500'
|
|
: 'h-1.5 w-1.5 bg-gray-200 dark:bg-gray-700'}"
|
|
></span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="mb-6 text-center">
|
|
<div class="mb-4 flex justify-center">
|
|
<div
|
|
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary-50 text-3xl dark:bg-primary-900/30"
|
|
>
|
|
{currentStep.icon}
|
|
</div>
|
|
</div>
|
|
<h2
|
|
id="onboarding-title"
|
|
class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100"
|
|
>
|
|
{currentStep.title}
|
|
</h2>
|
|
|
|
{#if currentStep.kind === 'text'}
|
|
<p class="text-sm leading-relaxed text-gray-600 dark:text-gray-300">
|
|
{currentStep.body}
|
|
</p>
|
|
{:else}
|
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-300">
|
|
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)}
|
|
{@const selected = $themePreference === opt.value}
|
|
<button
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={selected}
|
|
onclick={() => themePreference.set(opt.value)}
|
|
class="flex w-full cursor-pointer items-center gap-3 rounded-xl border-2 p-3 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-200
|
|
{selected
|
|
? 'border-blue-600 bg-blue-50 dark:border-blue-500 dark:bg-blue-950/40'
|
|
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800'}"
|
|
>
|
|
<span class="text-2xl leading-none">{opt.icon}</span>
|
|
<div class="flex-1">
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{opt.label}</p>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">{opt.hint}</p>
|
|
</div>
|
|
<span
|
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2
|
|
{selected
|
|
? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500'
|
|
: 'border-gray-300 dark:border-gray-600'}"
|
|
>
|
|
{#if selected}
|
|
<svg
|
|
class="h-3 w-3 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="3"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
{/if}
|
|
</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Buttons -->
|
|
<div class="flex gap-2">
|
|
<button
|
|
onclick={dismiss}
|
|
class="flex-1 rounded-xl border border-gray-200 py-3 text-sm text-gray-600 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800"
|
|
>
|
|
Überspringen
|
|
</button>
|
|
<button onclick={next} class="btn btn-primary flex-1">
|
|
{step < steps.length - 1 ? 'Weiter' : "Los geht's!"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|