feat(eventsnap): UX review batch-3 — feed virtualization, a11y/UX fixes, shared primitives
Frontend - Feed: DOM-windowing via @tanstack/svelte-virtual (new VirtualFeed). The window virtualizer keeps the sticky header, pull-to-refresh, infinite-scroll sentinel and bottom nav working. List = dynamic measured heights keyed by upload id + anchorTo:start so an SSE prepend doesn't jump a scrolled reader; grid = measured square rows. Drops the content-visibility band-aid and the old FeedGrid. measureElement(null) on row unmount prevents ResizeObserver retention; stable option callbacks + guarded setOptions avoid O(n) re-measure on like/comment patches. - Global: viewport-fit=cover (activates safe-area insets), lang=de, PWA manifest + maskable icon + apple-touch metas, prefers-reduced-motion, safe-area-top headers. - Feed reactivity: like/comment SSE patch the single card in place; upload-processed debounced in-place merge; IntersectionObserver leak fixed; shared 60s clock. - CLS: list cards reserve the skeleton aspect box. - Destructive actions (promote/demote/unban, gallery release) routed through ConfirmSheet (host + admin). - Export OOM: streamed download via single-use ticket instead of res.blob(). - Shared primitives: IconButton (44px hit area), scrollLock action; UploadSheet a11y. - Polish: api timeout + non-JSON guard, focus-trap offsetParent fix, pull-to-refresh passive:false + guards, diashow keyboard a11y, Toaster assertive errors, dark-mode gaps, aria-pressed on like/chip state, CameraCapture mic-on-video + retry, ConfirmSheet spinner, upload beforeunload warning, emoji->SVG icons. Backend - social.rs: like/comment SSE broadcasts now carry the fresh count so feed clients patch one card in place instead of refetching page 1. - admin.rs / main.rs: supporting changes for the above. Verified: svelte-check 0 errors, frontend build clean, /feed SSR 200. Runtime feed scroll-feel validation still owed (documented in FOLLOWUPS.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import { toast, toastError } from '$lib/toast-store';
|
||||
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
|
||||
interface StatsDto {
|
||||
user_count: number;
|
||||
@@ -136,6 +137,24 @@
|
||||
|
||||
const myRole = getRole();
|
||||
|
||||
// Generic confirm-then-run for irreversible / privilege-changing actions
|
||||
// (promote, demote, unban, release gallery). Reuses the shared ConfirmSheet.
|
||||
interface PendingConfirm {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
tone: 'default' | 'danger';
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
let confirmAction = $state<PendingConfirm | null>(null);
|
||||
|
||||
async function runConfirmAction() {
|
||||
const action = confirmAction;
|
||||
if (!action) return;
|
||||
await action.run();
|
||||
confirmAction = null;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const token = getToken();
|
||||
const role = getRole();
|
||||
@@ -173,7 +192,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Keys rendered as number inputs — used to reject empty/invalid numeric saves.
|
||||
const NUMBER_KEYS = new Set(
|
||||
CONFIG_GROUPS.flatMap((g) => g.fields.filter((f) => f.kind === 'number').map((f) => f.key))
|
||||
);
|
||||
|
||||
async function saveConfig() {
|
||||
// Don't let a cleared number field persist as an empty/NaN config value.
|
||||
for (const key of NUMBER_KEYS) {
|
||||
const v = configDraft[key];
|
||||
if (v !== undefined && v !== config[key] && (String(v).trim() === '' || !Number.isFinite(Number(v)))) {
|
||||
toastError(new Error('Bitte gib für alle Zahlenfelder einen gültigen Wert ein.'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
const changes: Record<string, string> = {};
|
||||
@@ -323,6 +355,17 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Confirmation for irreversible / privilege-changing actions (promote/demote/unban/release). -->
|
||||
<ConfirmSheet
|
||||
open={confirmAction !== null}
|
||||
title={confirmAction?.title ?? ''}
|
||||
message={confirmAction?.message ?? ''}
|
||||
confirmLabel={confirmAction?.confirmLabel ?? 'Bestätigen'}
|
||||
tone={confirmAction?.tone ?? 'default'}
|
||||
onConfirm={runConfirmAction}
|
||||
onCancel={() => (confirmAction = null)}
|
||||
/>
|
||||
|
||||
<!-- PIN reset confirmation — pure yes/no, uses the shared ConfirmSheet. -->
|
||||
<ConfirmSheet
|
||||
open={pinResetTarget !== null}
|
||||
@@ -380,17 +423,13 @@
|
||||
|
||||
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<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-3xl items-center gap-3 px-4 py-4">
|
||||
<button
|
||||
onclick={() => goto('/account')}
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
aria-label="Zurück"
|
||||
>
|
||||
<IconButton label="Zurück" onclick={() => goto('/account')} class="shrink-0">
|
||||
<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="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
</button>
|
||||
</IconButton>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Admin-Dashboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -414,7 +453,7 @@
|
||||
{#if loading}
|
||||
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
|
||||
{:else if error}
|
||||
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div>
|
||||
<div role="alert" class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">{error}</div>
|
||||
{:else}
|
||||
|
||||
<!-- ── Stats tab ────────────────────────────────────────────────── -->
|
||||
@@ -500,6 +539,8 @@
|
||||
id={field.key}
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
inputmode="decimal"
|
||||
bind:value={configDraft[field.key]}
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
@@ -532,7 +573,13 @@
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h3 class="mb-3 font-semibold text-gray-900 dark:text-gray-100">Galerie</h3>
|
||||
<button
|
||||
onclick={releaseGallery}
|
||||
onclick={() => (confirmAction = {
|
||||
title: 'Galerie freigeben?',
|
||||
message: 'Gäste können dann alle Fotos herunterladen. Das kann nicht rückgängig gemacht werden.',
|
||||
confirmLabel: 'Freigeben',
|
||||
tone: 'danger',
|
||||
run: releaseGallery
|
||||
})}
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||
>
|
||||
Galerie freigeben
|
||||
@@ -625,17 +672,35 @@
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||
{#if user.role !== 'admin'}
|
||||
{#if user.is_banned}
|
||||
<button onclick={() => unban(user)} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600">
|
||||
<button onclick={() => (confirmAction = {
|
||||
title: 'Sperre aufheben?',
|
||||
message: `${user.display_name} kann danach wieder hochladen, liken und kommentieren.`,
|
||||
confirmLabel: 'Entsperren',
|
||||
tone: 'default',
|
||||
run: () => unban(user)
|
||||
})} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600">
|
||||
Entsperren
|
||||
</button>
|
||||
{:else}
|
||||
{#if user.role === 'guest'}
|
||||
<button onclick={() => promoteToHost(user)} class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60">
|
||||
<button onclick={() => (confirmAction = {
|
||||
title: 'Zum Host befördern?',
|
||||
message: `${user.display_name} erhält Host-Rechte: sperren, PIN zurücksetzen und Galerie verwalten. Das lässt sich nur durch Degradieren rückgängig machen.`,
|
||||
confirmLabel: 'Befördern',
|
||||
tone: 'default',
|
||||
run: () => promoteToHost(user)
|
||||
})} class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60">
|
||||
Host
|
||||
</button>
|
||||
{/if}
|
||||
{#if user.role === 'host'}
|
||||
<button onclick={() => demoteToGuest(user)} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600">
|
||||
<button onclick={() => (confirmAction = {
|
||||
title: 'Zum Gast degradieren?',
|
||||
message: `${user.display_name} verliert alle Host-Rechte.`,
|
||||
confirmLabel: 'Degradieren',
|
||||
tone: 'danger',
|
||||
run: () => demoteToGuest(user)
|
||||
})} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600">
|
||||
Degradieren
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user