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:
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user