feat(frontend): UX review followups — primitives + a11y/UX fixes across 4 passes

New shared primitives:
- Toaster + toast-store, ConfirmSheet, Modal, focusTrap action,
  pullToRefresh action, avatarPalette + initials helper, Skeleton,
  HeartBurst, haptics, export-status store with onClearAuth hook

Critical UX/a11y:
- Replaced window.confirm with branded ConfirmSheet
- Focus management + Escape on every modal (PIN, Lightbox,
  Onboarding, ContextSheet, data-mode sheet, leave-confirm,
  HTML guide, host/admin ban + PIN-display modals)
- Sheet backdrops are real buttons with aria-label
- Silent ApiError catches now surface via global Toaster

Major polish:
- Dark-mode parity on HashtagChips + avatars (shared palette)
- Conditional Export tab in BottomNav (badge dot when ZIP ready)
- Back chevrons on /recover (history-aware) and /export
- Upload composer discard confirmation when content is staged
- Camera segmented Photo/Video shutter
- PIN auto-submit on 4th digit, paste-flash-free (controlled input)
- Welcome-back toast on /feed after PIN recovery

Minor:
- Skeleton states on feed; pull-to-refresh with live drag indicator
- Haptics on like / capture / submit / PIN-copy / onboarding complete
- Comment 500-char counter; quota "Fast voll" / "Limit erreicht" labels
- Onboarding pip ≥24px tap targets; long-press hint step
- overscroll-behavior lock on <html> while feed mounted
- teardownExportStatus wired via onClearAuth (covers 401 + explicit logout)
- ConfirmSheet per-instance titleId; Modal requires titleId or ariaLabel

Tests (7 new Playwright specs):
- 01-auth/pin-auto-submit, 01-auth/back-chevron
- 03-feed/confirm-sheet-delete, 03-feed/toast-on-failure
- 09-mobile/focus-trap, 09-mobile/sheet-escape,
  09-mobile/upload-cancel-confirm

FOLLOWUPS.md captures the deferred AT inert containment work
with acceptance criteria + implementation sketches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-24 22:50:28 +02:00
parent b241ba6415
commit 309c25bc06
36 changed files with 1751 additions and 433 deletions

View File

@@ -6,6 +6,7 @@
import { onMount, onDestroy } from 'svelte';
import BottomNav from '$lib/components/BottomNav.svelte';
import UploadSheet from '$lib/components/UploadSheet.svelte';
import Toaster from '$lib/components/Toaster.svelte';
import { showBottomNav } from '$lib/ui-store';
import { isAuthenticated } from '$lib/auth';
import { queueItems, isProcessing } from '$lib/upload-queue';
@@ -39,7 +40,8 @@
const ctx = await api.get<MeContextDto>('/me/context');
privacyNote.set(ctx.privacy_note);
} catch {
// non-fatal; users without a session land on /join anyway
// Cross-cutting hydration on boot — failure is non-fatal; users without
// a session land on /join anyway, and the per-page mount will retry.
}
void refreshQuota();
}
@@ -49,12 +51,15 @@
// `currentPin` store carries the change into any page that reads it (My
// Account in particular).
unsubs.push(
// Server contract: `data` is a JSON string of the shape `{ user_id: UUID }`.
// We clear the cached PIN only for our own user; admin resets for other guests
// arrive on the same channel but aren't ours to act on.
onSseEvent('pin-reset', (data) => {
try {
const payload = JSON.parse(data) as { user_id: string };
if (payload.user_id === getUserId()) clearPin();
} catch {
// ignore malformed payload
// Malformed payload — discard; nothing actionable for the user.
}
})
);
@@ -90,3 +95,5 @@
{#if $showBottomNav && $isAuthenticated}
<BottomNav />
{/if}
<Toaster />

View File

@@ -9,6 +9,10 @@
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);
@@ -91,15 +95,20 @@
if (!value) return;
navigator.clipboard.writeText(value);
pinCopied = true;
vibrate([0, 8, 60, 8]);
setTimeout(() => (pinCopied = false), 2000);
}
async function handleLogout() {
try { await api.delete('/session'); } catch { /* ignore */ }
// 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.
try { await api.delete('/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 { /* ignore */ }
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');
}
@@ -124,13 +133,6 @@
}
}
function avatarColor(name: string | null): string {
if (!name) return 'bg-gray-100 text-gray-500';
const COLORS = ['bg-blue-100 text-blue-700','bg-purple-100 text-purple-700','bg-green-100 text-green-700','bg-amber-100 text-amber-700','bg-rose-100 text-rose-700'];
let hash = 0;
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) & 0xff;
return COLORS[hash % COLORS.length];
}
</script>
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
@@ -147,9 +149,9 @@
<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
{avatarColor(displayName)}"
{avatarPalette(displayName)}"
>
{displayName ? displayName[0].toUpperCase() : '?'}
{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>
@@ -384,68 +386,50 @@
<!-- Data-mode warning bottom sheet — shown once when the user picks Original. -->
{#if dataModeWarningOpen}
<div class="fixed inset-0 z-50 flex items-end bg-black/40" onclick={() => (dataModeWarningOpen = false)}>
<div
class="w-full rounded-t-2xl bg-white px-5 pb-10 pt-6 dark:bg-gray-900"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<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 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="mb-3 w-full rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white transition hover:bg-blue-700"
>
Aktivieren
</button>
<button
onclick={() => (dataModeWarningOpen = false)}
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
Abbrechen
</button>
<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="mb-3 w-full rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 active:bg-blue-700"
>
Aktivieren
</button>
<button
onclick={() => (dataModeWarningOpen = false)}
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-700 transition 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"
>
Abbrechen
</button>
</div>
{/if}
<!-- Leave-confirm bottom sheet -->
{#if leaveConfirmOpen}
<div class="fixed inset-0 z-50 flex items-end bg-black/40" onclick={() => (leaveConfirmOpen = false)}>
<div
class="w-full rounded-t-2xl bg-white px-5 pb-10 pt-6 dark:bg-gray-900"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<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 class="mb-1 text-center text-lg font-bold text-gray-900 dark:text-gray-100">Event verlassen?</h3>
<p class="mb-6 text-center text-sm text-gray-500 dark:text-gray-400">
Du wirst abgemeldet. Mit deinem PIN kannst du jederzeit zurückkehren.
</p>
<button
onclick={handleLogout}
class="mb-3 w-full rounded-xl bg-red-600 py-3 text-sm font-semibold text-white transition hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400"
>
Abmelden
</button>
<button
onclick={() => (leaveConfirmOpen = false)}
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
Abbrechen
</button>
</div>
</div>
{/if}
<ConfirmSheet
open={leaveConfirmOpen}
title="Event verlassen?"
message="Du wirst abgemeldet. Mit deinem PIN kannst du jederzeit zurückkehren."
confirmLabel="Abmelden"
tone="danger"
onConfirm={handleLogout}
onCancel={() => (leaveConfirmOpen = false)}
/>

View File

@@ -3,6 +3,9 @@
import { getToken, getRole } from '$lib/auth';
import { api } from '$lib/api';
import { onMount } from 'svelte';
import { toast, toastError } from '$lib/toast-store';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import Modal from '$lib/components/Modal.svelte';
interface StatsDto {
user_count: number;
@@ -110,7 +113,6 @@
let loading = $state(true);
let saving = $state(false);
let error = $state<string | null>(null);
let toast = $state<string | null>(null);
let exportJobsRefreshing = $state(false);
// Nutzer tab state
@@ -171,11 +173,6 @@
}
}
function showToast(msg: string) {
toast = msg;
setTimeout(() => (toast = null), 3000);
}
async function saveConfig() {
saving = true;
try {
@@ -186,14 +183,14 @@
}
}
if (Object.keys(changes).length === 0) {
showToast('Keine Änderungen.');
toast('Keine Änderungen.', 'info');
return;
}
await api.patch('/admin/config', changes);
config = { ...configDraft };
showToast('Konfiguration gespeichert.');
toast('Konfiguration gespeichert.', 'success');
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler beim Speichern.');
toastError(e);
} finally {
saving = false;
}
@@ -202,9 +199,9 @@
async function releaseGallery() {
try {
await api.post('/host/gallery/release');
showToast('Galerie wurde freigegeben. Export wird vorbereitet…');
toast('Galerie wurde freigegeben. Export wird vorbereitet…', 'success');
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
toastError(e);
}
}
@@ -218,11 +215,11 @@
banSubmitting = true;
try {
await api.post(`/host/users/${banTarget.id}/ban`, { hide_uploads: banHideUploads });
showToast(`${banTarget.display_name} wurde gesperrt.`);
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
banTarget = null;
users = await api.get<UserSummary[]>('/host/users');
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
toastError(e);
} finally {
banSubmitting = false;
}
@@ -231,30 +228,30 @@
async function unban(user: UserSummary) {
try {
await api.post(`/host/users/${user.id}/unban`);
showToast(`Sperre für ${user.display_name} aufgehoben.`);
toast(`Sperre für ${user.display_name} aufgehoben.`, 'success');
users = await api.get<UserSummary[]>('/host/users');
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
toastError(e);
}
}
async function promoteToHost(user: UserSummary) {
try {
await api.patch(`/host/users/${user.id}/role`, { role: 'host' });
showToast(`${user.display_name} ist jetzt Host.`);
toast(`${user.display_name} ist jetzt Host.`, 'success');
users = await api.get<UserSummary[]>('/host/users');
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
toastError(e);
}
}
async function demoteToGuest(user: UserSummary) {
try {
await api.patch(`/host/users/${user.id}/role`, { role: 'guest' });
showToast(`${user.display_name} ist jetzt Gast.`);
toast(`${user.display_name} ist jetzt Gast.`, 'success');
users = await api.get<UserSummary[]>('/host/users');
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
toastError(e);
}
}
@@ -270,7 +267,7 @@
pinModal = { name: pinResetTarget.display_name, pin: res.pin };
pinResetTarget = null;
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler beim Zurücksetzen.');
toastError(e);
} finally {
pinResetSubmitting = false;
}
@@ -279,7 +276,7 @@
function copyPinModal() {
if (!pinModal) return;
navigator.clipboard.writeText(pinModal.pin);
showToast('PIN kopiert.');
toast('PIN kopiert.', 'success');
}
/** True iff the current caller may reset this target's PIN. Mirrors the backend
@@ -326,76 +323,60 @@
}
</script>
<!-- PIN reset confirmation -->
{#if pinResetTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">PIN zurücksetzen</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Eine neue PIN für <strong>{pinResetTarget.display_name}</strong> wird erzeugt. Die alte PIN funktioniert dann nicht mehr.
</p>
<div class="flex gap-2">
<button onclick={() => (pinResetTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">Abbrechen</button>
<button onclick={confirmResetPin} disabled={pinResetSubmitting} class="flex-1 rounded-lg bg-amber-500 py-2 text-sm font-medium text-white hover:bg-amber-600 disabled:opacity-50 dark:bg-amber-500 dark:hover:bg-amber-400">
{pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
</button>
</div>
</div>
</div>
{/if}
<!-- PIN reset confirmation — pure yes/no, uses the shared ConfirmSheet. -->
<ConfirmSheet
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.`
: ''}
confirmLabel={pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
tone="danger"
onConfirm={confirmResetPin}
onCancel={() => (pinResetTarget = null)}
/>
<!-- One-time PIN display modal -->
{#if pinModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Neue PIN für {pinModal.name}</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Zeige diese PIN dem Benutzer. Sie wird nur einmal angezeigt — beim Schließen wird sie verworfen.
</p>
<div class="mb-4 flex items-center justify-between rounded-lg bg-amber-50 px-4 py-3 dark:bg-amber-950/30">
<span class="font-mono text-3xl font-bold tracking-widest text-gray-900 dark:text-gray-100">{pinModal.pin}</span>
<button onclick={copyPinModal} class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60">
Kopieren
</button>
</div>
<button
onclick={() => (pinModal = null)}
class="w-full rounded-lg bg-blue-600 py-2 text-sm font-semibold text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
>
Schließen
<!-- One-time PIN display modal — focus-trapped, aria-modal, Escape-dismissable. -->
<Modal open={pinModal !== null} titleId="admin-pin-modal-title" onClose={() => (pinModal = null)}>
{#if pinModal}
<h2 id="admin-pin-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Neue PIN für {pinModal.name}</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Zeige diese PIN dem Benutzer. Sie wird nur einmal angezeigt — beim Schließen wird sie verworfen.
</p>
<div class="mb-4 flex items-center justify-between rounded-lg bg-amber-50 px-4 py-3 dark:bg-amber-950/30">
<span class="font-mono text-3xl font-bold tracking-widest text-gray-900 dark:text-gray-100">{pinModal.pin}</span>
<button onclick={copyPinModal} class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-200 active:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60 dark:active:bg-amber-900/60">
Kopieren
</button>
</div>
</div>
{/if}
<button
onclick={() => (pinModal = null)}
class="w-full rounded-lg bg-blue-600 py-2 text-sm font-semibold text-white hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400"
>
Schließen
</button>
{/if}
</Modal>
<!-- Ban modal -->
{#if banTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
</p>
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<input type="checkbox" bind:checked={banHideUploads} class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600" />
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
</label>
<div class="flex gap-2">
<button onclick={() => (banTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">Abbrechen</button>
<button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400">
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
</button>
</div>
<!-- Ban modal — checkbox-bearing, so uses the Modal shell instead of ConfirmSheet. -->
<Modal open={banTarget !== null} titleId="admin-ban-modal-title" onClose={() => (banTarget = null)}>
{#if banTarget}
<h2 id="admin-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
</p>
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<input type="checkbox" bind:checked={banHideUploads} class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600" />
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
</label>
<div class="flex gap-2">
<button onclick={() => (banTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 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">Abbrechen</button>
<button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 active:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400">
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
</button>
</div>
</div>
{/if}
<!-- Toast -->
{#if toast}
<div class="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-gray-900 px-5 py-2.5 text-sm text-white shadow-lg dark:bg-gray-100 dark:text-gray-900">
{toast}
</div>
{/if}
{/if}
</Modal>
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
<!-- Header -->

View File

@@ -4,6 +4,8 @@
import { api } from '$lib/api';
import { onMount, onDestroy } from 'svelte';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import { toastError } from '$lib/toast-store';
import { focusTrap } from '$lib/actions/focus-trap';
interface JobStatus {
status: 'locked' | 'pending' | 'running' | 'done' | 'failed';
@@ -52,7 +54,8 @@
try {
status = await api.get<ExportStatus>('/export/status');
} catch {
// ignore
// Background poll triggered by SSE — silent. The visible empty/loading state
// will reflect the failure; the next event will retry.
} finally {
loading = false;
}
@@ -73,18 +76,25 @@
}
async function downloadFile(endpoint: string, filename: string) {
const token = getToken();
const res = await fetch(endpoint, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
try {
const token = getToken();
const res = await fetch(endpoint, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (!res.ok) {
toastError(new Error(`Download fehlgeschlagen (${res.status}).`));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
toastError(e);
}
}
function downloadZip() {
@@ -109,8 +119,14 @@
<!-- HTML guide modal -->
{#if showHtmlGuide}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
<h2 class="mb-3 text-lg font-bold text-gray-900 dark:text-gray-100">Hinweis zum HTML-Viewer</h2>
<div
class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900"
role="dialog"
aria-modal="true"
aria-labelledby="html-guide-title"
use:focusTrap={{ onclose: () => (showHtmlGuide = false) }}
>
<h2 id="html-guide-title" class="mb-3 text-lg font-bold text-gray-900 dark:text-gray-100">Hinweis zum HTML-Viewer</h2>
<ol class="mb-4 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<li class="flex gap-2"><span class="font-bold text-blue-600 dark:text-blue-400">1.</span> ZIP-Datei entpacken (Windows: Rechtsklick → "Alle extrahieren"; Mac: Doppelklick).</li>
<li class="flex gap-2"><span class="font-bold text-blue-600 dark:text-blue-400">2.</span> <strong>index.html</strong> im Browser öffnen.</li>
@@ -139,7 +155,18 @@
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
<div class="border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div class="mx-auto flex max-w-lg items-center px-4 py-4">
<div class="mx-auto flex max-w-lg items-center gap-2 px-4 py-4">
<button
type="button"
onclick={() => goto('/feed')}
data-testid="export-back"
class="-ml-2 flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100 active:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-800 dark:active:bg-gray-700"
aria-label="Zurück"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
</button>
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Export</h1>
</div>
</div>

View File

@@ -10,7 +10,12 @@
import LightboxModal from '$lib/components/LightboxModal.svelte';
import OnboardingGuide from '$lib/components/OnboardingGuide.svelte';
import ContextSheet, { type ContextAction } from '$lib/components/ContextSheet.svelte';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import { refreshQuota } from '$lib/quota-store';
import { toast, toastError } from '$lib/toast-store';
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
import { vibrate } from '$lib/haptics';
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
let uploads = $state<FeedUpload[]>([]);
@@ -18,8 +23,26 @@
let selectedHashtag = $state<string | null>(null);
let nextCursor = $state<string | null>(null);
let loadingMore = $state(false);
let initialLoading = $state(true);
let refreshing = $state(false);
let pullProgress = $state(0); // 01+ during the drag, 0 when idle
let selectedUpload = $state<FeedUpload | null>(null);
let sentinel: HTMLDivElement;
let pendingDeleteId = $state<string | null>(null);
// ─────────────────────────────────────────────────────────────────────────
// onMount A — DOM side-effects only (overscroll lock). Synchronous, returns
// its own cleanup. Kept separate from the data-loading onMount below so its
// cleanup can't be accidentally clobbered when someone edits the async one.
// ─────────────────────────────────────────────────────────────────────────
onMount(() => {
if (typeof document === 'undefined') return;
const prev = document.documentElement.style.overscrollBehaviorY;
document.documentElement.style.overscrollBehaviorY = 'contain';
return () => {
document.documentElement.style.overscrollBehaviorY = prev;
};
});
// View mode
let viewMode = $state<'list' | 'grid'>('list');
@@ -54,7 +77,7 @@
label: 'Löschen',
icon: '🗑',
tone: 'danger',
onClick: () => deleteUpload(target.id)
onClick: () => { pendingDeleteId = target.id; }
});
}
return actions;
@@ -64,15 +87,17 @@
contextTarget = upload;
}
async function deleteUpload(id: string) {
if (!confirm('Diesen Beitrag wirklich löschen?')) return;
async function confirmDelete() {
const id = pendingDeleteId;
if (!id) return;
pendingDeleteId = null;
try {
await api.delete(`/upload/${id}`);
uploads = uploads.filter((u) => u.id !== id);
if (selectedUpload?.id === id) selectedUpload = null;
void refreshQuota();
} catch {
// ignore — toast handled by ApiError elsewhere
} catch (e) {
toastError(e);
}
}
@@ -133,12 +158,28 @@
});
});
// ─────────────────────────────────────────────────────────────────────────
// onMount B — auth gate, recovery toast, data load, SSE subscriptions,
// infinite-scroll observer. Cleanup of the SSE handlers lives in onDestroy
// below (not in a returned cleanup) because this callback is async.
// ─────────────────────────────────────────────────────────────────────────
onMount(async () => {
if (!getToken()) {
goto('/join');
return;
}
// Surface the welcome-back toast set by /recover. Lives here (not on /account)
// because /feed is the first hydrated route after a successful PIN recovery —
// the toast should land in the same beat as the "you're in" feeling.
if (typeof sessionStorage !== 'undefined') {
const welcome = sessionStorage.getItem('eventsnap_just_recovered');
if (welcome) {
sessionStorage.removeItem('eventsnap_just_recovered');
toast(`Willkommen zurück, ${welcome}!`, 'success');
}
}
await Promise.all([loadFeed(), loadHashtags()]);
connectSse();
@@ -202,7 +243,12 @@
const res = await api.get<FeedResponse>(`/feed?${params}`);
uploads = res.uploads;
nextCursor = res.next_cursor;
} catch { /* ignore */ }
} catch (e) {
// Initial / user-triggered refresh is worth surfacing — background SSE refetches are noisier and silenced below.
if (!refresh) toastError(e);
} finally {
initialLoading = false;
}
}
async function loadMore() {
@@ -216,7 +262,9 @@
const res = await api.get<FeedResponse>(`/feed?${params}`);
uploads = [...uploads, ...res.uploads];
nextCursor = res.next_cursor;
} catch { /* ignore */ } finally {
} catch (e) {
toastError(e);
} finally {
loadingMore = false;
}
}
@@ -224,7 +272,22 @@
async function loadHashtags() {
try {
hashtags = await api.get<HashtagCount[]>('/hashtags');
} catch { /* ignore */ }
} catch {
// Hashtag panel is a discoverability nicety — silent fail is acceptable; the feed still works.
}
}
async function pullRefresh() {
if (refreshing) return;
refreshing = true;
pullProgress = 0;
vibrate(10);
try {
nextCursor = null;
await Promise.all([loadFeed(true), loadHashtags()]);
} finally {
refreshing = false;
}
}
function selectHashtag(tag: string | null) {
@@ -236,6 +299,7 @@
async function handleLike(id: string) {
try {
await api.post(`/upload/${id}/like`);
vibrate(10);
uploads = uploads.map((u) =>
u.id === id
? { ...u, liked_by_me: !u.liked_by_me, like_count: u.liked_by_me ? u.like_count - 1 : u.like_count + 1 }
@@ -248,7 +312,9 @@
like_count: selectedUpload.liked_by_me ? selectedUpload.like_count - 1 : selectedUpload.like_count + 1,
};
}
} catch { /* ignore */ }
} catch (e) {
toastError(e);
}
}
function openComments(id: string) {
@@ -282,9 +348,45 @@
}
</script>
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
<!-- Sticky header -->
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 backdrop-blur dark:border-gray-800 dark:bg-gray-900/95">
<div
class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950"
use:pullToRefresh={{
onrefresh: pullRefresh,
onpull: (_, progress) => (pullProgress = progress),
disabled: initialLoading
}}
>
<!-- Live pull-progress indicator: grows during the drag, rotates past threshold,
swaps to a spinner once the network refresh kicks off. -->
{#if refreshing || pullProgress > 0}
<div class="pointer-events-none fixed left-0 right-0 top-2 z-40 flex justify-center">
<div
class="rounded-full bg-white/90 px-3 py-1 text-xs font-medium text-blue-600 shadow transition-opacity dark:bg-gray-900/90 dark:text-blue-300"
style="opacity: {refreshing ? 1 : Math.min(1, pullProgress)}"
>
{#if refreshing}
<span class="inline-flex items-center gap-2">
<span class="inline-block h-3 w-3 animate-spin rounded-full border-2 border-blue-200 border-t-blue-600 dark:border-blue-700 dark:border-t-blue-300"></span>
Aktualisiere…
</span>
{:else}
<svg
class="inline-block h-4 w-4 transition-transform"
style="transform: rotate({Math.min(180, pullProgress * 180)}deg)"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg>
{/if}
</div>
</div>
{/if}
<!-- Sticky header — opaque fallback for browsers without backdrop-filter. -->
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 backdrop-blur supports-[not(backdrop-filter:blur(0))]:bg-white dark:border-gray-800 dark:bg-gray-900/95 dark:supports-[not(backdrop-filter:blur(0))]:bg-gray-900">
<div class="mx-auto flex max-w-2xl items-center justify-between px-4 py-3">
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Galerie</h1>
@@ -347,7 +449,11 @@
type="search"
placeholder="Nutzer oder #Tag suchen…"
bind:value={searchQuery}
onfocus={() => (showAutocomplete = true)}
onfocus={(e) => {
showAutocomplete = true;
// Push the input above the virtual keyboard so suggestions stay visible.
(e.currentTarget as HTMLInputElement).scrollIntoView({ block: 'center', behavior: 'smooth' });
}}
onblur={() => setTimeout(() => (showAutocomplete = false), 150)}
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none dark:text-gray-100 dark:placeholder-gray-500"
/>
@@ -412,7 +518,21 @@
</div>
<!-- Content -->
{#if uploads.length === 0}
{#if initialLoading && uploads.length === 0}
<div class="mx-auto max-w-2xl" data-testid="feed-skeleton">
{#if viewMode === 'list'}
{#each Array(3) as _}
<Skeleton variant="card" />
{/each}
{:else}
<div class="grid grid-cols-3 gap-0.5">
{#each Array(9) as _}
<Skeleton variant="tile" />
{/each}
</div>
{/if}
</div>
{:else if uploads.length === 0}
<div class="py-20 text-center">
<p class="text-lg text-gray-400 dark:text-gray-500">Noch keine Fotos.</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Tippe auf den Plus-Button unten!</p>
@@ -479,5 +599,16 @@
onClose={() => (contextTarget = null)}
/>
<!-- Branded delete confirmation — replaces window.confirm() -->
<ConfirmSheet
open={pendingDeleteId !== null}
title="Beitrag löschen?"
message="Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Löschen"
tone="danger"
onConfirm={confirmDelete}
onCancel={() => (pendingDeleteId = null)}
/>
<!-- First-visit onboarding guide -->
<OnboardingGuide />

View File

@@ -3,6 +3,9 @@
import { getToken, getRole } from '$lib/auth';
import { api } from '$lib/api';
import { onMount } from 'svelte';
import { toast, toastError } from '$lib/toast-store';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import Modal from '$lib/components/Modal.svelte';
interface UserSummary {
id: string;
@@ -51,8 +54,6 @@
let pinResetSubmitting = $state(false);
let pinModal = $state<{ name: string; pin: string } | null>(null);
let toast = $state<string | null>(null);
const myRole = getRole();
/** Mirrors backend `handlers::host::reset_user_pin` authorisation rules. */
@@ -88,34 +89,29 @@
}
}
function showToast(msg: string) {
toast = msg;
setTimeout(() => (toast = null), 3000);
}
async function toggleEventLock() {
if (!event) return;
try {
if (event.uploads_locked) {
await api.post('/host/event/open');
showToast('Uploads wurden wieder geöffnet.');
toast('Uploads wurden wieder geöffnet.', 'success');
} else {
await api.post('/host/event/close');
showToast('Uploads wurden gesperrt.');
toast('Uploads wurden gesperrt.', 'success');
}
await reload();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
toastError(e);
}
}
async function releaseGallery() {
try {
await api.post('/host/gallery/release');
showToast('Galerie wurde freigegeben. Export wird vorbereitet…');
toast('Galerie wurde freigegeben. Export wird vorbereitet…', 'success');
await reload();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
toastError(e);
}
}
@@ -129,11 +125,11 @@
banSubmitting = true;
try {
await api.post(`/host/users/${banTarget.id}/ban`, { hide_uploads: banHideUploads });
showToast(`${banTarget.display_name} wurde gesperrt.`);
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
banTarget = null;
await reload();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
toastError(e);
} finally {
banSubmitting = false;
}
@@ -142,30 +138,30 @@
async function unban(user: UserSummary) {
try {
await api.post(`/host/users/${user.id}/unban`);
showToast(`Sperre für ${user.display_name} aufgehoben.`);
toast(`Sperre für ${user.display_name} aufgehoben.`, 'success');
await reload();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
toastError(e);
}
}
async function promoteToHost(user: UserSummary) {
try {
await api.patch(`/host/users/${user.id}/role`, { role: 'host' });
showToast(`${user.display_name} ist jetzt Host.`);
toast(`${user.display_name} ist jetzt Host.`, 'success');
await reload();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
toastError(e);
}
}
async function demoteToGuest(user: UserSummary) {
try {
await api.patch(`/host/users/${user.id}/role`, { role: 'guest' });
showToast(`${user.display_name} ist jetzt Gast.`);
toast(`${user.display_name} ist jetzt Gast.`, 'success');
await reload();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
toastError(e);
}
}
@@ -181,7 +177,7 @@
pinModal = { name: pinResetTarget.display_name, pin: res.pin };
pinResetTarget = null;
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler beim Zurücksetzen.');
toastError(e);
} finally {
pinResetSubmitting = false;
}
@@ -190,7 +186,7 @@
function copyPinModal() {
if (!pinModal) return;
navigator.clipboard.writeText(pinModal.pin);
showToast('PIN kopiert.');
toast('PIN kopiert.', 'success');
}
function formatBytes(bytes: number): string {
@@ -200,89 +196,73 @@
}
</script>
<!-- PIN reset confirmation -->
{#if pinResetTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">PIN zurücksetzen</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Eine neue PIN für <strong>{pinResetTarget.display_name}</strong> wird erzeugt. Die alte PIN funktioniert dann nicht mehr.
</p>
<div class="flex gap-2">
<button onclick={() => (pinResetTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">Abbrechen</button>
<button onclick={confirmResetPin} disabled={pinResetSubmitting} class="flex-1 rounded-lg bg-amber-500 py-2 text-sm font-medium text-white hover:bg-amber-600 disabled:opacity-50 dark:bg-amber-500 dark:hover:bg-amber-400">
{pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
</button>
</div>
</div>
</div>
{/if}
<!-- PIN reset confirmation — pure yes/no, uses the shared ConfirmSheet. -->
<ConfirmSheet
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.`
: ''}
confirmLabel={pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
tone="danger"
onConfirm={confirmResetPin}
onCancel={() => (pinResetTarget = null)}
/>
<!-- One-time PIN display modal -->
{#if pinModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Neue PIN für {pinModal.name}</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Zeige diese PIN dem Benutzer. Sie wird nur einmal angezeigt — beim Schließen wird sie verworfen.
</p>
<div class="mb-4 flex items-center justify-between rounded-lg bg-amber-50 px-4 py-3 dark:bg-amber-950/30">
<span class="font-mono text-3xl font-bold tracking-widest text-gray-900 dark:text-gray-100">{pinModal.pin}</span>
<button onclick={copyPinModal} class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60">
Kopieren
</button>
</div>
<button
onclick={() => (pinModal = null)}
class="w-full rounded-lg bg-blue-600 py-2 text-sm font-semibold text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
>
Schließen
<!-- One-time PIN display modal — focus-trapped, aria-modal, Escape-dismissable. -->
<Modal open={pinModal !== null} titleId="host-pin-modal-title" onClose={() => (pinModal = null)}>
{#if pinModal}
<h2 id="host-pin-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Neue PIN für {pinModal.name}</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Zeige diese PIN dem Benutzer. Sie wird nur einmal angezeigt — beim Schließen wird sie verworfen.
</p>
<div class="mb-4 flex items-center justify-between rounded-lg bg-amber-50 px-4 py-3 dark:bg-amber-950/30">
<span class="font-mono text-3xl font-bold tracking-widest text-gray-900 dark:text-gray-100">{pinModal.pin}</span>
<button onclick={copyPinModal} class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-200 active:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60 dark:active:bg-amber-900/60">
Kopieren
</button>
</div>
</div>
{/if}
<button
onclick={() => (pinModal = null)}
class="w-full rounded-lg bg-blue-600 py-2 text-sm font-semibold text-white hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400"
>
Schließen
</button>
{/if}
</Modal>
<!-- Ban modal -->
{#if banTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
</p>
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<input
type="checkbox"
bind:checked={banHideUploads}
class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
</label>
<div class="flex gap-2">
<button
onclick={() => (banTarget = null)}
class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
Abbrechen
</button>
<button
onclick={confirmBan}
disabled={banSubmitting}
class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400"
>
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
</button>
</div>
<!-- Ban modal — needs a checkbox so it's not a pure ConfirmSheet, but still gets the same a11y shell. -->
<Modal open={banTarget !== null} titleId="host-ban-modal-title" onClose={() => (banTarget = null)}>
{#if banTarget}
<h2 id="host-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
</p>
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<input
type="checkbox"
bind:checked={banHideUploads}
class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
</label>
<div class="flex gap-2">
<button
onclick={() => (banTarget = null)}
class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 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"
>
Abbrechen
</button>
<button
onclick={confirmBan}
disabled={banSubmitting}
class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 active:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400"
>
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
</button>
</div>
</div>
{/if}
<!-- Toast -->
{#if toast}
<div class="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-gray-900 px-5 py-2.5 text-sm text-white shadow-lg dark:bg-gray-100 dark:text-gray-900">
{toast}
</div>
{/if}
{/if}
</Modal>
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
<!-- Header -->

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth } from '$lib/auth';
import { focusTrap } from '$lib/actions/focus-trap';
let displayName = $state('');
let error = $state('');
@@ -84,6 +85,28 @@
function goToFeed() {
goto('/feed');
}
function closePinModal() {
// setAuth has already run on join success — the user is authenticated.
// Closing the PIN reminder while leaving them on /join would render the
// (already-completed) join form again. Honor the dismissal by routing
// them where they actually want to be.
showPinModal = false;
goto('/feed');
}
// Strip non-digits synchronously in the input handler so paste of "1234X"
// never flashes the longer string. Auto-submits on the 4th digit so the
// user doesn't have to chase the (now cosmetic) Anmelden button.
function onRecoveryPinInput(e: Event) {
const el = e.currentTarget as HTMLInputElement;
const cleaned = el.value.replace(/\D/g, '').slice(0, 4);
if (cleaned !== el.value) el.value = cleaned;
recoveryPin = cleaned;
if (recoveryPin.length === 4 && !recoveryLoading) {
handleInlineRecover();
}
}
</script>
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
@@ -106,7 +129,8 @@
<form onsubmit={(e) => { e.preventDefault(); handleInlineRecover(); }}>
<input
type="text"
bind:value={recoveryPin}
value={recoveryPin}
oninput={onRecoveryPinInput}
placeholder="4-stelliger PIN"
maxlength={4}
inputmode="numeric"
@@ -176,8 +200,14 @@
{#if showPinModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4" data-testid="pin-modal">
<div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-lg dark:bg-gray-900">
<h2 class="mb-2 text-xl font-bold text-gray-900 dark:text-gray-100">Dein Wiederherstellungs-PIN</h2>
<div
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-lg dark:bg-gray-900"
role="dialog"
aria-modal="true"
aria-labelledby="pin-modal-title"
use:focusTrap={{ onclose: closePinModal }}
>
<h2 id="pin-modal-title" class="mb-2 text-xl font-bold text-gray-900 dark:text-gray-100">Dein Wiederherstellungs-PIN</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Merke dir diesen PIN! Du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen.
</p>
@@ -187,7 +217,7 @@
<button
onclick={copyPin}
data-testid="pin-copy"
class="min-h-11 min-w-11 rounded-md bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
class="min-h-11 min-w-11 rounded-md bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 dark:active:bg-gray-600"
>
{copied ? 'Kopiert!' : 'Kopieren'}
</button>
@@ -196,10 +226,17 @@
<button
onclick={goToFeed}
data-testid="continue-to-feed"
class="w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white transition hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
class="mb-2 w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white transition hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400"
>
Weiter zur Galerie
</button>
<button
type="button"
onclick={closePinModal}
class="w-full rounded-lg py-2 text-sm text-gray-500 hover:text-gray-700 active:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 dark:active:text-gray-200"
>
Schließen
</button>
</div>
</div>
{/if}

View File

@@ -1,9 +1,19 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth, getPin } from '$lib/auth';
import { setAuth, getPin, getToken } from '$lib/auth';
import { browser } from '$app/environment';
function goBack() {
// Prefer the actual previous page (most users land here from /join or /account).
// Fall back to a sensible default based on auth state for deep-linked users.
if (browser && window.history.length > 1) {
window.history.back();
return;
}
goto(getToken() ? '/feed' : '/join');
}
let displayName = $state('');
let pin = $state('');
let error = $state('');
@@ -26,6 +36,9 @@
}>('/recover', { display_name: displayName.trim(), pin: pin.trim() });
setAuth(res.jwt, pin.trim(), res.user_id, displayName.trim());
// Surface a welcome-back toast on /feed after navigation. sessionStorage
// scopes the cue to the next page load so it doesn't replay on refresh.
if (browser) sessionStorage.setItem('eventsnap_just_recovered', displayName.trim());
goto('/feed');
} catch (e) {
if (e instanceof ApiError) {
@@ -37,10 +50,38 @@
loading = false;
}
}
// Strip non-digits synchronously in the input handler (not via $effect on
// bind:value) so a paste of "1234X" doesn't flash the longer string between
// the bind setting `pin` and the reactive cleanup reassigning it. Mutating
// el.value before Svelte's next render means the field never displays the
// invalid intermediate state.
function onPinInput(e: Event) {
const el = e.currentTarget as HTMLInputElement;
const cleaned = el.value.replace(/\D/g, '').slice(0, 4);
if (cleaned !== el.value) el.value = cleaned;
pin = cleaned;
if (pin.length === 4 && displayName.trim() && !loading) {
handleRecover();
}
}
</script>
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
<div class="w-full max-w-sm">
<div class="flex min-h-screen flex-col bg-gray-50 px-4 dark:bg-gray-950">
<div class="-mx-4 flex items-center px-2 py-3">
<button
type="button"
onclick={goBack}
data-testid="recover-back"
class="flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100 active:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-800 dark:active:bg-gray-700"
aria-label="Zurück"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
</button>
</div>
<div class="m-auto w-full max-w-sm">
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Konto wiederherstellen</h1>
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">Gib deinen Namen und deinen PIN ein.</p>
@@ -55,7 +96,8 @@
/>
<input
type="text"
bind:value={pin}
value={pin}
oninput={onPinInput}
placeholder="4-stelliger PIN"
maxlength={4}
inputmode="numeric"

View File

@@ -7,6 +7,8 @@
import { get } from 'svelte/store';
import { onMount, onDestroy } from 'svelte';
import { quotaStore, refreshQuota } from '$lib/quota-store';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import { vibrate } from '$lib/haptics';
import type { PendingFile } from '$lib/pending-upload-store';
interface StagedFile extends PendingFile {
@@ -17,6 +19,7 @@
let caption = $state('');
let submitting = $state(false);
let captionEl: HTMLTextAreaElement;
let discardConfirmOpen = $state(false);
const MAX_CAPTION_LENGTH = 2000;
@@ -66,6 +69,16 @@
}
function cancel() {
if (stagedFiles.length > 0 || caption.trim().length > 0) {
discardConfirmOpen = true;
return;
}
clearPending();
goto('/feed');
}
function confirmDiscard() {
discardConfirmOpen = false;
clearPending();
goto('/feed');
}
@@ -74,6 +87,7 @@
if (stagedFiles.length === 0 || submitting) return;
if (caption.length > MAX_CAPTION_LENGTH) return;
submitting = true;
vibrate(10);
const hashtagsString = captionTags.join(',');
for (const sf of stagedFiles) {
await addToQueue(sf.file, caption, hashtagsString);
@@ -221,12 +235,29 @@
style="width: {quotaPercent}%"
></div>
</div>
{#if quotaPercent >= 100}
<p class="mt-1 font-medium text-red-600 dark:text-red-400">Limit erreicht — bitte alte Beiträge löschen.</p>
{:else if quotaPercent >= 95}
<p class="mt-1 font-medium text-amber-600 dark:text-amber-400">Fast voll.</p>
{/if}
</div>
{/if}
<div class="h-8"></div>
</div>
<!-- Discard confirmation — appears only when the composer has unsaved content. -->
<ConfirmSheet
open={discardConfirmOpen}
title="Verwerfen?"
message="Deine Auswahl und der Text gehen verloren."
confirmLabel="Verwerfen"
cancelLabel="Weiter bearbeiten"
tone="danger"
onConfirm={confirmDiscard}
onCancel={() => (discardConfirmOpen = false)}
/>
<!-- Sticky submit button at bottom (mobile-primary) -->
<div class="border-t border-gray-100 px-4 py-3 dark:border-gray-800">
<button