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

@@ -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 -->