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" +>