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>
627 lines
22 KiB
Svelte
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)}
|
|
/>
|