From e79e02056635875ed55c09ec88c56a5ce21c2802 Mon Sep 17 00:00:00 2001 From: fabi Date: Tue, 30 Jun 2026 19:16:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(eventsnap):=20UX=20review=20batch-3=20?= =?UTF-8?q?=E2=80=94=20feed=20virtualization,=20a11y/UX=20fixes,=20shared?= =?UTF-8?q?=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- FOLLOWUPS.md | 49 +++ backend/src/handlers/admin.rs | 40 ++- backend/src/handlers/social.rs | 22 +- backend/src/main.rs | 1 + e2e/specs/06-export/export.spec.ts | 12 +- .../09-mobile/gestures-longpress.spec.ts | 2 +- frontend/package-lock.json | 55 ++-- frontend/package.json | 1 + frontend/src/app.css | 14 + frontend/src/app.html | 17 +- frontend/src/lib/actions/doubletap.ts | 30 +- frontend/src/lib/actions/focus-trap.ts | 5 +- frontend/src/lib/actions/pull-to-refresh.ts | 53 +++- frontend/src/lib/actions/scroll-lock.ts | 50 +++ frontend/src/lib/api.ts | 43 ++- .../src/lib/components/CameraCapture.svelte | 36 ++- .../src/lib/components/ConfirmSheet.svelte | 11 +- .../src/lib/components/ContextSheet.svelte | 8 + frontend/src/lib/components/FeedGrid.svelte | 91 ------ .../src/lib/components/FeedListCard.svelte | 69 ++-- .../src/lib/components/HashtagChips.svelte | 6 +- frontend/src/lib/components/IconButton.svelte | 55 ++++ .../src/lib/components/LightboxModal.svelte | 13 +- frontend/src/lib/components/Modal.svelte | 2 + .../src/lib/components/OnboardingGuide.svelte | 2 + frontend/src/lib/components/Toaster.svelte | 4 +- .../src/lib/components/UploadQueue.svelte | 34 +- .../src/lib/components/UploadSheet.svelte | 64 +++- .../src/lib/components/VirtualFeed.svelte | 295 ++++++++++++++++++ frontend/src/lib/data-mode-store.ts | 2 +- frontend/src/lib/now.ts | 10 + frontend/src/routes/+page.svelte | 9 +- frontend/src/routes/account/+page.svelte | 27 +- frontend/src/routes/admin/+page.svelte | 89 +++++- frontend/src/routes/diashow/+page.svelte | 54 +++- frontend/src/routes/export/+page.svelte | 45 ++- frontend/src/routes/feed/+page.svelte | 103 ++++-- frontend/src/routes/host/+page.svelte | 76 ++++- frontend/src/routes/recover/+page.svelte | 13 +- frontend/src/routes/upload/+page.svelte | 29 +- frontend/static/icon.svg | 12 + frontend/static/manifest.webmanifest | 20 ++ 42 files changed, 1245 insertions(+), 328 deletions(-) create mode 100644 frontend/src/lib/actions/scroll-lock.ts delete mode 100644 frontend/src/lib/components/FeedGrid.svelte create mode 100644 frontend/src/lib/components/IconButton.svelte create mode 100644 frontend/src/lib/components/VirtualFeed.svelte create mode 100644 frontend/src/lib/now.ts create mode 100644 frontend/static/icon.svg create mode 100644 frontend/static/manifest.webmanifest diff --git a/FOLLOWUPS.md b/FOLLOWUPS.md index d268f06..4a85582 100644 --- a/FOLLOWUPS.md +++ b/FOLLOWUPS.md @@ -46,6 +46,55 @@ is the smallest patch. - [frontend/src/lib/components/Toaster.svelte](frontend/src/lib/components/Toaster.svelte) — add passthrough marker (if approach 2) or move to a portal (if approach 1) - [frontend/src/app.html](frontend/src/app.html) — add ` - {/each} - diff --git a/frontend/src/lib/components/FeedListCard.svelte b/frontend/src/lib/components/FeedListCard.svelte index 3830a19..b5c71d4 100644 --- a/frontend/src/lib/components/FeedListCard.svelte +++ b/frontend/src/lib/components/FeedListCard.svelte @@ -6,11 +6,9 @@ import { doubletap } from '$lib/actions/doubletap'; import { avatarPalette, initials } from '$lib/avatar'; import { vibrate } from '$lib/haptics'; + import { now } from '$lib/now'; import HeartBurst from './HeartBurst.svelte'; - // Single-tap debounce so a double-tap doesn't briefly open the lightbox. - const SINGLE_TAP_DELAY_MS = 260; - interface Props { upload: FeedUpload; isOwn?: boolean; @@ -35,8 +33,8 @@ const mediaSrc = $derived(pickMediaUrl($dataMode, upload)); - function relativeTime(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); + function relativeTime(iso: string, nowMs: number): string { + const diff = nowMs - new Date(iso).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return 'gerade eben'; if (mins < 60) return `vor ${mins} Min.`; @@ -46,44 +44,35 @@ return `vor ${days} Tag${days === 1 ? '' : 'en'}`; } + // Re-derives off the shared 60s clock so "vor 5 Min." actually advances. + const relTime = $derived(relativeTime(upload.created_at, $now)); + function openContext() { oncontextmenu?.(upload); } // Inline heart-burst on double-tap (consistent with the lightbox). let heartBurst = $state(false); - let singleTapTimer: ReturnType | null = null; - - function handleMediaClick() { - // Delay single-tap so a quick second tap (double-tap-to-like) wins. - if (singleTapTimer) clearTimeout(singleTapTimer); - singleTapTimer = setTimeout(() => { - singleTapTimer = null; - onselect(upload); - }, SINGLE_TAP_DELAY_MS); - } + let burstTimer: ReturnType | null = null; function handleDoubleTap() { - if (singleTapTimer) { - clearTimeout(singleTapTimer); - singleTapTimer = null; - } heartBurst = true; vibrate(10); onlike(upload.id); - setTimeout(() => (heartBurst = false), 700); + if (burstTimer) clearTimeout(burstTimer); + burstTimer = setTimeout(() => (heartBurst = false), 700); } - // A feed-delta SSE event can remove this card mid-pending-tap. Clear the timer - // on unmount so we don't call onselect with a stale upload reference. + // Clear the burst timer on unmount (a feed-delta SSE event can remove this + // card mid-animation) so it can't fire against a stale component. onDestroy(() => { - if (singleTapTimer) { - clearTimeout(singleTapTimer); - singleTapTimer = null; - } + if (burstTimer) clearTimeout(burstTimer); }); +

{upload.uploader_name}

-

{relativeTime(upload.created_at)}

+

{relTime}

@@ -120,9 +109,10 @@ diff --git a/frontend/src/lib/components/LightboxModal.svelte b/frontend/src/lib/components/LightboxModal.svelte index 314c700..88f597d 100644 --- a/frontend/src/lib/components/LightboxModal.svelte +++ b/frontend/src/lib/components/LightboxModal.svelte @@ -1,10 +1,12 @@ {#if items.length > 0} -
-
-

+
+
+

Upload-Warteschlange {#if $isProcessing} @@ -61,7 +61,7 @@ {#if hasCompleted} @@ -69,18 +69,18 @@

{#if $rateLimitRetryAt && countdown > 0} -
+
Upload-Limit erreicht. Wird in {countdown} Sek. automatisch fortgesetzt.
{/if} -
    +
      {#each items as item (item.id)}
    • -

      {item.fileName}

      -

      {formatSize(item.fileSize)}

      +

      {item.fileName}

      +

      {formatSize(item.fileSize)}

      @@ -89,7 +89,7 @@ {#if item.status === 'error'} @@ -97,7 +97,7 @@ {#if item.status === 'done' || item.status === 'error'}
      {#if item.status === 'uploading'} -
      +
      -

      {item.progress}%

      +

      {item.progress}%

      {/if} {#if item.error} -

      {item.error}

      +

      {item.error}

      {/if}
    • {/each} diff --git a/frontend/src/lib/components/UploadSheet.svelte b/frontend/src/lib/components/UploadSheet.svelte index 4158e7d..bfd9a8c 100644 --- a/frontend/src/lib/components/UploadSheet.svelte +++ b/frontend/src/lib/components/UploadSheet.svelte @@ -2,11 +2,14 @@ import { goto } from '$app/navigation'; import { uploadSheetOpen } from '$lib/ui-store'; import { pendingFiles } from '$lib/pending-upload-store'; + import { scrollLock } from '$lib/actions/scroll-lock'; import CameraCapture from '$lib/components/CameraCapture.svelte'; import type { PendingFile } from '$lib/pending-upload-store'; let showCamera = $state(false); let fileInput: HTMLInputElement; + let sheet = $state(null); + let returnFocus: HTMLElement | null = null; // Keep the sheet and backdrop always in the DOM for smooth CSS transitions. let open = $derived($uploadSheetOpen); @@ -15,6 +18,47 @@ uploadSheetOpen.set(false); } + // Focus-trap + Escape, wired manually because the sheet stays mounted for its + // translate-y animation (so use:focusTrap, which activates on mount, won't do). + // Mirrors ContextSheet. Suspended while the camera overlay owns the screen. + function onKeyDown(e: KeyboardEvent) { + if (showCamera) return; + if (e.key === 'Escape') { + e.preventDefault(); + close(); + return; + } + if (e.key !== 'Tab' || !sheet) return; + const list = Array.from(sheet.querySelectorAll('button:not([disabled])')); + if (list.length === 0) return; + const first = list[0]; + const last = list[list.length - 1]; + const active = document.activeElement as HTMLElement | null; + if (e.shiftKey && (active === first || !sheet.contains(active))) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } + + $effect(() => { + if (open) { + returnFocus = (document.activeElement as HTMLElement | null) ?? null; + requestAnimationFrame(() => { + if (showCamera) return; + const first = sheet?.querySelector('button:not([disabled])'); + first?.focus({ preventScroll: true }); + }); + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + } else if (returnFocus) { + try { returnFocus.focus({ preventScroll: true }); } catch { /* element gone */ } + returnFocus = null; + } + }); + function openGallery() { fileInput?.click(); } @@ -68,22 +112,34 @@ onchange={handleFiles} /> - -
      +{#if open && !showCamera} + +{/if} + + +
      + tabindex="-1" + aria-label="Schließen" +>