The upload queue gains `parkedFor`, so a photo rejected for a reason that cannot change on its own stops re-pushing itself. A ban used to come back as a generic `forbidden`, which purged the blob and moved the row to `blocked` — a terminal state with no retry button — so lifting a ban restored everything except the photo actually in flight. Ban and release are now distinct codes that keep the blob, charge no attempt, and tell the guest what has to happen. `releaseResolvedParks` drains them at boot from /me/context, because the live `user-shown` / `event-opened` events only reach a tab that was open when the host acted, and the usual sequence is the other way round. Two signals were firing on nothing. A filtered feed set `feedStale` on EVERY delta without deduping — and the delta cursor boundary is inclusive while sse.ts deliberately rewinds `lastEventTime`, so deltas routinely re-return rows already delivered. With the backstop polling every 60-120s, a guest who tapped a hashtag got a "Neue Beiträge" pill they could never clear, each tap costing a full filtered refetch. It now dedupes in both branches. The SSE liveness backstop had the mirror problem: `noteDelivered` harvested id, upload_id AND user_id from every payload, so by the time anything was deleted or anyone banned, their ids were already marked delivered from ordinary traffic about live content. The `deleted_ids` and `hidden_user_ids` clauses were false essentially always, leaving a half-open socket undetected while a host moderated into a feed nobody was listening to. Each event now records only the id its own clause tests. Also: /admin no longer bounces to /join on a cleared session — AUTH_ROUTES had the `/admin` prefix, which suppressed clearAuth() on the dashboard and let the login guard bounce back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
706 lines
25 KiB
Svelte
706 lines
25 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, ApiError } 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 { toast } from '$lib/toast-store';
|
|
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');
|
|
}
|
|
|
|
let deleteConfirmOpen = $state(false);
|
|
let deleting = $state(false);
|
|
|
|
/**
|
|
* Erase this account. Unlike `handleLogout`, the server call is NOT best-effort — if it fails
|
|
* we must keep the guest signed in and say so, because clearing local auth on a failed delete
|
|
* would leave them believing their photos were gone while every one of them is still live.
|
|
*/
|
|
async function handleDeleteAccount() {
|
|
if (deleting) return;
|
|
deleting = true;
|
|
try {
|
|
await api.delete('/me');
|
|
} catch (e) {
|
|
deleteConfirmOpen = false;
|
|
deleting = false;
|
|
toast(
|
|
e instanceof ApiError ? e.message : 'Löschen fehlgeschlagen. Bitte versuch es erneut.',
|
|
'error',
|
|
6000
|
|
);
|
|
return;
|
|
}
|
|
// Only now is it safe to tear down locally. The queue must go too: its blobs are this
|
|
// guest's photos, and leaving them would re-upload everything on the next session.
|
|
try {
|
|
await clearQueue();
|
|
} catch {
|
|
/* best-effort cleanup */
|
|
}
|
|
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) -->
|
|
<!-- `data-testid` rather than an accessible-name locator: this trigger and the
|
|
confirm sheet's button are BOTH labelled "Abmelden", so `getByRole('button',
|
|
{ name: /abmelden/i })` is ambiguous the moment the sheet opens. The rename
|
|
away from "Event verlassen" silently broke three e2e locators — including the
|
|
one behind the smoke spec — precisely because they keyed on visible copy. -->
|
|
<button
|
|
data-testid="account-logout"
|
|
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>
|
|
|
|
<!-- Erasure. The join page's data notice promises this exists, and until now nothing
|
|
did: there was no user-deletion route at any role, so honouring "please remove my
|
|
photos" meant hand-written SQL against production. -->
|
|
<button
|
|
data-testid="account-delete"
|
|
onclick={() => (deleteConfirmOpen = 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="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
|
|
/>
|
|
</svg>
|
|
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400"
|
|
>Konto und alle Fotos löschen</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)}
|
|
/>
|
|
|
|
<!-- Erasure confirm. Separate sheet from the logout one because the copy has to be unambiguous
|
|
about the difference: logging out keeps everything, this destroys it. -->
|
|
<ConfirmSheet
|
|
open={deleteConfirmOpen}
|
|
title="Konto und alle Fotos löschen?"
|
|
message="Alle deine Fotos, Bildtexte, Kommentare und Likes werden endgültig gelöscht — auch aus der Diashow und aus dem Erinnerungs-Archiv. Das lässt sich nicht rückgängig machen."
|
|
confirmLabel="Endgültig löschen"
|
|
tone="danger"
|
|
onConfirm={handleDeleteAccount}
|
|
onCancel={() => (deleteConfirmOpen = false)}
|
|
/>
|