Files
EventSnap/frontend/src/routes/account/+page.svelte
Fabian Hamm (Privat) 2b1500e624 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>
2026-08-03 18:36:57 +02:00

627 lines
22 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getDisplayName, getExpiry, clearAuth, currentPin } from '$lib/auth';
import { role, setRole } from '$lib/role-store';
import { clearQueue } from '$lib/upload-queue';
import { api } from '$lib/api';
import { onMount, onDestroy } from 'svelte';
import { dataMode } from '$lib/data-mode-store';
import { themePreference, type ThemePreference } from '$lib/theme-store';
import { privacyNote } from '$lib/privacy-note-store';
import { quotaStore, refreshQuota } from '$lib/quota-store';
import { onSseEvent } from '$lib/sse';
import { focusTrap } from '$lib/actions/focus-trap';
import { avatarPalette, initials } from '$lib/avatar';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import { vibrate } from '$lib/haptics';
import type { MeContextDto } from '$lib/types';
let displayName = $state<string | null>(null);
let expiry = $state<Date | null>(null);
let pinCopied = $state(false);
let leaveConfirmOpen = $state(false);
let leaveEverywhere = $state(false);
let dataModeWarningOpen = $state(false);
// `pin` is sourced from the shared `currentPin` store so a global pin-reset SSE
// event (handled in the layout) clears the display live without a reload.
const pin = currentPin;
const unsubs: Array<() => void> = [];
onMount(async () => {
if (!getToken()) {
goto('/join');
return;
}
displayName = getDisplayName();
expiry = getExpiry();
// Refresh server-driven state. Quota + privacy note may have changed since last visit.
try {
const ctx = await api.get<MeContextDto>('/me/context');
privacyNote.set(ctx.privacy_note);
setRole(ctx.role);
} catch {
// non-fatal
}
void refreshQuota();
// If admin edits the privacy note while we're sitting on the page, pick it up.
unsubs.push(
onSseEvent('event-updated', async () => {
try {
const ctx = await api.get<MeContextDto>('/me/context');
privacyNote.set(ctx.privacy_note);
} catch {
// ignore
}
})
// Note: `pin-reset` is handled globally in the root layout.
);
});
onDestroy(() => {
for (const u of unsubs) u();
});
function chooseDataMode(mode: 'saver' | 'original') {
if (mode === 'original' && $dataMode !== 'original') {
dataModeWarningOpen = true;
return;
}
dataMode.set(mode);
}
function confirmOriginalMode() {
dataMode.set('original');
dataModeWarningOpen = false;
}
function formatBytes(bytes: number | null | undefined): string {
if (bytes == null || bytes <= 0) return '0 MB';
const mb = bytes / (1024 * 1024);
if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
return `${(mb / 1024).toFixed(1)} GB`;
}
const quotaPercent = $derived(
$quotaStore.limit_bytes && $quotaStore.limit_bytes > 0
? Math.min(100, ($quotaStore.used_bytes / $quotaStore.limit_bytes) * 100)
: 0
);
function copyPin() {
const value = $pin;
if (!value) return;
navigator.clipboard.writeText(value);
pinCopied = true;
vibrate([0, 8, 60, 8]);
setTimeout(() => (pinCopied = false), 2000);
}
async function handleLogout(everywhere = false) {
// Session-delete is best-effort: the JWT is going away on this device either way,
// so a network failure shouldn't block the user from leaving the event.
// `everywhere` revokes ALL of this user's sessions (other phones/laptops recovered
// via PIN), not just this device.
try {
await api.delete(everywhere ? '/sessions' : '/session');
} catch {
/* best-effort logout */
}
// Wipe the IndexedDB upload queue so a second guest using the same device can't
// inherit (or be blamed for) this guest's pending uploads. Not done on a 401
// auto-clear — that path preserves the queue in case the user re-authenticates.
try {
await clearQueue();
} catch {
/* best-effort cleanup */
}
// Note: export-status teardown is wired via the onClearAuth hook in
// export-status-store, so it runs for both this explicit path and api.ts's 401.
clearAuth();
goto('/join');
}
function formatDate(d: Date): string {
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
}
function roleLabel(r: string | null): string {
switch (r) {
case 'admin':
return 'Admin';
case 'host':
return 'Gastgeber';
default:
return 'Gast';
}
}
function roleColor(r: string | null): string {
switch (r) {
case 'admin':
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200';
case 'host':
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-200';
default:
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200';
}
}
</script>
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
<!-- Header -->
<div
class="border-b border-gray-200 bg-white pt-[env(safe-area-inset-top)] dark:border-gray-800 dark:bg-gray-900"
>
<div class="mx-auto flex max-w-lg items-center px-4 py-4">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Mein Konto</h1>
</div>
</div>
<div class="mx-auto max-w-lg space-y-3 p-4">
<!-- Profile card -->
<div class="card p-5">
<div class="flex items-center gap-4">
<div
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-xl font-bold
{avatarPalette(displayName)}"
>
{initials(displayName)}
</div>
<div class="min-w-0">
<p class="truncate text-lg font-bold text-gray-900 dark:text-gray-100">
{displayName ?? 'Unbekannt'}
</p>
<span
class="mt-0.5 inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold {roleColor(
$role
)}"
>
{roleLabel($role)}
</span>
</div>
</div>
{#if expiry}
<p class="mt-3 text-xs text-gray-400 dark:text-gray-500">
Sitzung gültig bis {formatDate(expiry)}
</p>
{/if}
</div>
<!-- Dashboards section (host + admin only) -->
{#if $role === 'host' || $role === 'admin'}
<div class="card overflow-hidden">
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
<h2
class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
Dashboards
</h2>
</div>
<a
href="/host"
class="flex items-center gap-3 px-5 py-4 transition hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<!-- Star icon -->
<svg
class="h-5 w-5 text-primary-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
/>
</svg>
<span class="flex-1 font-medium text-gray-900 dark:text-gray-100">Host-Dashboard</span>
<svg
class="h-4 w-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
{#if $role === 'admin'}
<a
href="/admin"
class="flex items-center gap-3 border-t border-gray-100 px-5 py-4 transition hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/50"
>
<!-- Shield icon -->
<svg
class="h-5 w-5 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
<span class="flex-1 font-medium text-gray-900 dark:text-gray-100">Admin-Dashboard</span>
<svg
class="h-4 w-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
{/if}
</div>
{/if}
<!-- PIN card -->
<div
class="rounded-xl border border-primary-200 bg-primary-50 p-5 dark:border-primary-800/60 dark:bg-primary-950/30"
>
<h2 class="mb-1 font-semibold text-primary-800 dark:text-primary-200">
Wiederherstellungs-PIN
</h2>
<p class="mb-3 text-sm text-primary-700 dark:text-primary-300/90">
Du brauchst diesen PIN, um dein Konto auf einem anderen Gerät wiederherzustellen. Schreib
ihn auf!
</p>
{#if $pin}
<div
class="flex items-center justify-between rounded-lg bg-white px-4 py-3 shadow-sm dark:bg-gray-900"
>
<span
class="font-mono text-4xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
>{$pin}</span
>
<button
onclick={copyPin}
class="inline-flex min-h-11 items-center rounded-md bg-primary-100 px-4 py-2 text-sm font-medium text-primary-800 transition hover:bg-primary-200 dark:bg-primary-900/40 dark:text-primary-200 dark:hover:bg-primary-900/60"
>
{pinCopied ? 'Kopiert!' : 'Kopieren'}
</button>
</div>
{:else}
<div
class="rounded-lg bg-white px-4 py-3 text-sm text-gray-400 shadow-sm dark:bg-gray-900 dark:text-gray-500"
>
PIN nicht gespeichert. Nutze die Wiederherstellungs-Seite, um dich mit deinem PIN
anzumelden.
</div>
{/if}
</div>
<!-- Diashow tile — primarily mobile, where the feed header doesn't show the icon. -->
<a
href="/diashow"
class="flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-5 py-4 transition hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700/50 sm:hidden"
>
<svg
class="h-5 w-5 text-purple-500 dark:text-purple-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 7.5A2.25 2.25 0 0 1 6 5.25h12A2.25 2.25 0 0 1 20.25 7.5v9A2.25 2.25 0 0 1 18 18.75H6A2.25 2.25 0 0 1 3.75 16.5v-9Z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9.75 14.5 12 10 14.25v-4.5Z" />
</svg>
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-gray-100">Diashow starten</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Vollbild-Präsentation der Beiträge</p>
</div>
<svg
class="h-4 w-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
<!-- Theme / Design -->
<div class="card overflow-hidden">
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Design
</h2>
</div>
<div class="grid grid-cols-3 gap-2 p-3" role="radiogroup" aria-label="Design">
{#each [{ value: 'system', label: 'System' }, { value: 'light', label: 'Hell' }, { value: 'dark', label: 'Dunkel' }] as opt (opt.value)}
{@const selected = $themePreference === opt.value}
<button
type="button"
role="radio"
aria-checked={selected}
onclick={() => themePreference.set(opt.value as ThemePreference)}
class="flex flex-col items-center gap-1 rounded-lg border-2 px-2 py-3 text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-200
{selected
? 'border-blue-600 bg-blue-50 text-blue-700 dark:border-blue-500 dark:bg-blue-950/40 dark:text-blue-200'
: 'border-gray-200 text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-700/50'}"
>
<!-- SVG icons render consistently everywhere (emoji show as tofu on some Android). -->
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
{#if opt.value === 'system'}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25A2.25 2.25 0 0 1 5.25 3h13.5A2.25 2.25 0 0 1 21 5.25Z"
/>
{:else if opt.value === 'light'}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.752 15.002A9.718 9.718 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z"
/>
{/if}
</svg>
<span class="font-medium">{opt.label}</span>
</button>
{/each}
</div>
</div>
<!-- Data mode -->
<div class="card overflow-hidden">
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Datennutzung
</h2>
</div>
<!--
Custom buttons styled as radios. The store is the single source of truth;
we don't lean on `<input type="radio">` because the native checked-state
model fights the "show confirm sheet first, then maybe commit" UX
(Svelte's reactive `checked={…}` and the browser's interactive state
model drift apart on cancel and on subsequent switches).
-->
<div class="px-5 py-4 space-y-2" role="radiogroup" aria-label="Datenmodus">
{#each [{ value: 'saver', title: 'Datensparer (empfohlen)', body: 'Lädt komprimierte Vorschauen. Schnell und mobildatenfreundlich.' }, { value: 'original', title: 'Original', body: 'Lädt die Originaldateien. Bessere Qualität, höherer Datenverbrauch.' }] as opt (opt.value)}
{@const selected = $dataMode === opt.value}
<button
type="button"
role="radio"
aria-checked={selected}
onclick={() => chooseDataMode(opt.value as 'saver' | 'original')}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg p-1 text-left transition hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-200 dark:hover:bg-gray-700/50"
>
<span
class="mt-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition {selected
? 'border-blue-600 dark:border-blue-400'
: 'border-gray-300 dark:border-gray-600'}"
>
{#if selected}
<span class="block h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400"></span>
{/if}
</span>
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">{opt.title}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{opt.body}</p>
</div>
</button>
{/each}
</div>
</div>
<!-- Per-user quota widget — staff-only (host/admin); guests never see the
server-derived storage figures. -->
{#if ($role === 'host' || $role === 'admin') && $quotaStore.enabled && $quotaStore.limit_bytes != null}
<div class="card p-5">
<h2
class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
Dein Speicherkontingent
</h2>
<div class="flex items-baseline justify-between">
<span class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{formatBytes($quotaStore.used_bytes)}
</span>
<span class="text-sm text-gray-500 dark:text-gray-400"
>von {formatBytes($quotaStore.limit_bytes)}</span
>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div
class="h-full transition-all"
class:bg-blue-500={quotaPercent < 80}
class:bg-amber-500={quotaPercent >= 80 && quotaPercent < 95}
class:bg-red-500={quotaPercent >= 95}
style="width: {quotaPercent}%"
></div>
</div>
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
Geschätzt für {$quotaStore.active_uploaders} aktive Beitragende.
</p>
</div>
{/if}
<!-- Datenschutzhinweis (preformatted, plain text) -->
{#if $privacyNote.trim()}
<div class="card p-5">
<h2
class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
Datenschutzhinweis
</h2>
<pre
class="whitespace-pre-wrap font-sans text-sm text-gray-700 dark:text-gray-300">{$privacyNote}</pre>
</div>
{/if}
<!-- Konto section -->
<div class="card overflow-hidden">
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Konto
</h2>
</div>
<!-- Recover / device switch -->
<a
href="/recover"
class="flex items-center gap-3 px-5 py-4 transition hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<svg
class="h-5 w-5 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 8.25h3m-3 3h3m-3 3h3"
/>
</svg>
<span class="flex-1 text-sm font-medium text-gray-700 dark:text-gray-300"
>Gerät wechseln / PIN nutzen</span
>
<svg
class="h-4 w-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
<!-- Leave / logout (this device) -->
<button
onclick={() => {
leaveEverywhere = false;
leaveConfirmOpen = true;
}}
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
>
<svg
class="h-5 w-5 text-red-500 dark:text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
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>
<!-- "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) -->
<button
onclick={() => {
leaveEverywhere = true;
leaveConfirmOpen = true;
}}
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
>
<svg
class="h-5 w-5 text-red-500 dark:text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
/>
</svg>
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400"
>Auf allen Geräten abmelden</span
>
</button>
</div>
</div>
</div>
<!-- Data-mode warning bottom sheet — shown once when the user picks Original. -->
{#if dataModeWarningOpen}
<button
type="button"
class="fixed inset-0 z-50 bg-black/40"
aria-label="Schließen"
tabindex="-1"
onclick={() => (dataModeWarningOpen = false)}
></button>
<div
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white px-5 pt-6 dark:bg-gray-900"
style="padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem)"
role="dialog"
aria-modal="true"
aria-labelledby="data-mode-title"
use:focusTrap={{ onclose: () => (dataModeWarningOpen = false) }}
>
<div class="mb-4 flex justify-center">
<div class="h-1 w-10 rounded-full bg-gray-300 dark:bg-gray-600"></div>
</div>
<h3
id="data-mode-title"
class="mb-1 text-center text-lg font-bold text-gray-900 dark:text-gray-100"
>
Original-Dateien laden?
</h3>
<p class="mb-6 text-center text-sm text-gray-500 dark:text-gray-400">
Original-Dateien können deutlich mehr Datenvolumen verbrauchen. Am besten im WLAN aktivieren.
</p>
<button onclick={confirmOriginalMode} class="btn btn-primary btn-block mb-3">
Aktivieren
</button>
<button onclick={() => (dataModeWarningOpen = false)} class="btn btn-secondary btn-block">
Abbrechen
</button>
</div>
{/if}
<!-- Leave-confirm bottom sheet -->
<ConfirmSheet
open={leaveConfirmOpen}
title={leaveEverywhere ? 'Auf allen Geräten abmelden?' : 'Abmelden?'}
message={leaveEverywhere
? '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)}
onCancel={() => (leaveConfirmOpen = false)}
/>