fix(feed): survive a bad network, and let the lightbox actually browse
Four things a guest on congested venue wifi would have hit, and one they would have
hit immediately.
A FAILED FEED LOAD CLAIMED THE GALLERY WAS EMPTY. `loadFeed` caught, toasted for five
seconds and left `uploads` empty, so the page fell through to "Noch keine Fotos. Tippe
auf den Kamera-Button unten!" — the most likely first impression at the party, and a
lie. There is now a distinct error state with "Erneut laden". Refreshes suppressed the
toast entirely, so pull-to-refresh and the "Neue Beiträge" pill failed in total
silence; they now report, and the pill survives its own failure instead of clearing
before the request.
THE FILTER-EMPTY STATE WAS DEAD CODE. With filtering server-side `displayUploads` is a
plain alias of `uploads`, so the grid's "Keine Treffer für die gewählten Filter." plus
its reset button sat behind an identical earlier branch and could never render — a
guest tapping a chip with no matches was told to go take a photo.
SSE COULD FREEZE THE FEED FOR THE WHOLE EVENING. Nothing in the feed ever refetched on
a timer; every update path was triggered exclusively by a stream event. Behind a proxy
that buffers `text/event-stream` `onopen` never fires, so the guest saw only the photos
that were on screen when they arrived; and a socket left half-open by an AP roam is
worse, because `connectSse` early-returns on a non-null EventSource and nothing ever
reconnects. A pure silence timer is not implementable — the backend sends keep-alives
as SSE comments, which the EventSource parser discards without dispatching — so
liveness is established on evidence instead: a jittered 60-120s `/feed/delta` backstop
that reconnects when a poll returns content the stream never delivered. The ticket
round-trip also seeds the delta cursor before the EventSource is created, so the
backstop has a `since` even if `onopen` never fires.
THE PILL COLLAPSED A DEEPLY-SCROLLED FEED to 20 items and dumped the guest at an
arbitrary scroll position — the exact yank the pill exists to avoid. It merges now.
The refresh debounce was 800ms + jitter, which during a burst is roughly one feed query
per client every two seconds; at 100 guests that approaches the 60/min per-user limit,
and the resulting 429s were swallowed by a bare `catch {}`, so the feed would simply
stop updating with no signal. Now 8s + jitter, coalescing, and skipped entirely while
the page is hidden.
Not one `<img>` in the app had an `onerror`. `pickMediaUrl` falls back to the original
whenever preview and thumbnail are null — i.e. for everything still compressing, which
during a burst is the top of the feed — so a 404 there rendered an empty grey box with
`alt=""`, not even a message. Each now retries once, then shows the placeholder.
The lightbox had no swipe, no prev/next and no arrow keys, so browsing 300 photos meant
closing and reopening the modal for every one — while FEATURES.md and USER_JOURNEYS
both claimed swipe shipped. It now has chevrons (44px, German aria-labels, hidden at
the ends), arrow keys, and horizontal swipe, with focus handed to the surviving control
so a disappearing chevron can't drop focus to `<body>`. Comment deletion was a ~14px
`✕` four pixels from the text that deleted permanently on one tap, while deleting a
POST two components away goes through a ConfirmSheet; it now matches.
`feed-filter.ts` and its test are deleted — with the server filtering, they were dead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,53 @@
|
||||
|
||||
const mediaSrc = $derived(pickMediaUrl($dataMode, upload));
|
||||
|
||||
// Video cards show a poster if one exists; `pickMediaUrl` is not used here because it is
|
||||
// mime-agnostic and would hand back the raw MP4 for an <img>.
|
||||
const posterSrc = $derived(upload.thumbnail_url ?? upload.preview_url ?? '');
|
||||
|
||||
// ── Media fallback ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A failed <img> rendered as an empty grey box with `alt=""` — no icon, no message,
|
||||
// nothing to tap. And the failure is routine rather than exotic: `pickMediaUrl` falls
|
||||
// back to `/original` whenever preview AND thumbnail are still null, i.e. for everything
|
||||
// still compressing, which in a newest-first feed is the card at the top during every
|
||||
// burst. That also makes the cause usually TRANSIENT, so one delayed retry of the same
|
||||
// URL recovers most of them; the nonce exists because the browser would otherwise replay
|
||||
// its cached failure rather than re-request. Two strikes and we fall through to the
|
||||
// placeholder this card already draws for "no derivative yet".
|
||||
const MEDIA_RETRY_MS = 4000;
|
||||
let mediaRetryNonce = $state(0);
|
||||
let mediaFailed = $state(false);
|
||||
let mediaRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function withNonce(url: string): string {
|
||||
if (!url || !mediaRetryNonce) return url;
|
||||
return `${url}${url.includes('?') ? '&' : '?'}r=${mediaRetryNonce}`;
|
||||
}
|
||||
|
||||
const displaySrc = $derived(withNonce(isVideo(upload.mime_type) ? posterSrc : mediaSrc));
|
||||
|
||||
// A url change — the SSE `upload-processed` swapping the original for a real preview is
|
||||
// the common one — deserves a clean attempt rather than inheriting the old verdict.
|
||||
$effect(() => {
|
||||
void mediaSrc;
|
||||
void posterSrc;
|
||||
mediaRetryNonce = 0;
|
||||
mediaFailed = false;
|
||||
});
|
||||
|
||||
function handleMediaError() {
|
||||
if (mediaRetryNonce) {
|
||||
mediaFailed = true;
|
||||
return;
|
||||
}
|
||||
if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
|
||||
mediaRetryTimer = setTimeout(() => {
|
||||
mediaRetryTimer = null;
|
||||
mediaRetryNonce = Date.now();
|
||||
}, MEDIA_RETRY_MS);
|
||||
}
|
||||
|
||||
function relativeTime(iso: string, nowMs: number): string {
|
||||
const diff = nowMs - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
@@ -61,6 +108,7 @@
|
||||
// card mid-animation) so it can't fire against a stale component.
|
||||
onDestroy(() => {
|
||||
if (burstTimer) clearTimeout(burstTimer);
|
||||
if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -124,13 +172,14 @@
|
||||
<HeartBurst active={heartBurst} />
|
||||
{#if isVideo(upload.mime_type)}
|
||||
<div class="relative aspect-video w-full bg-gray-900">
|
||||
{#if upload.thumbnail_url || upload.preview_url}
|
||||
{#if posterSrc && !mediaFailed}
|
||||
<img
|
||||
src={upload.thumbnail_url ?? upload.preview_url ?? ''}
|
||||
src={displaySrc}
|
||||
alt=""
|
||||
class="h-full w-full object-cover opacity-80"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror={handleMediaError}
|
||||
/>
|
||||
{/if}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
@@ -143,17 +192,18 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mediaSrc}
|
||||
{:else if mediaSrc && !mediaFailed}
|
||||
<!-- Reserve the same 4/5 box the skeleton uses so the card doesn't collapse
|
||||
to height 0 and reflow as images stream in. The uncropped original is one
|
||||
tap away in the lightbox. -->
|
||||
<div class="aspect-[4/5] w-full bg-gray-100 dark:bg-gray-800">
|
||||
<img
|
||||
src={mediaSrc}
|
||||
src={displaySrc}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror={handleMediaError}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -209,6 +259,7 @@
|
||||
{#if $commentsEnabled}
|
||||
<button
|
||||
onclick={() => oncomment(upload.id)}
|
||||
aria-label="Kommentare anzeigen"
|
||||
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import type { FeedUpload } from '$lib/types';
|
||||
import { api } from '$lib/api';
|
||||
import { onSseEvent } from '$lib/sse';
|
||||
@@ -14,6 +15,7 @@
|
||||
import { vibrate } from '$lib/haptics';
|
||||
import { commentsEnabled } from '$lib/event-config-store';
|
||||
import HeartBurst from './HeartBurst.svelte';
|
||||
import ConfirmSheet from './ConfirmSheet.svelte';
|
||||
|
||||
const COMMENT_MAX = 500;
|
||||
|
||||
@@ -30,9 +32,41 @@
|
||||
upload: FeedUpload;
|
||||
onclose: () => void;
|
||||
onlike: (id: string) => void;
|
||||
/** Whether a previous/next item exists in the caller's list. Controls the chevrons. */
|
||||
hasPrev?: boolean;
|
||||
hasNext?: boolean;
|
||||
onprev?: () => void;
|
||||
onnext?: () => void;
|
||||
}
|
||||
|
||||
let { upload, onclose, onlike }: Props = $props();
|
||||
let {
|
||||
upload,
|
||||
onclose,
|
||||
onlike,
|
||||
hasPrev = false,
|
||||
hasNext = false,
|
||||
onprev,
|
||||
onnext
|
||||
}: Props = $props();
|
||||
|
||||
// The system back gesture must close the photo, not leave the app.
|
||||
//
|
||||
// On a phone, back is how you dismiss a full-screen view — but this modal is component
|
||||
// state, not a route, so back navigated away from /feed entirely and the guest lost their
|
||||
// scroll position. Since the modal is only mounted while open, this subscription is
|
||||
// active exactly while it is open (Svelte tears it down on destroy).
|
||||
//
|
||||
// Cancelling a popstate navigation is explicitly supported: SvelteKit counteracts it with
|
||||
// `history.go`, restoring the entry we just left (see its client runtime, "if a
|
||||
// popstate-driven navigation is cancelled"). So the URL stays put and the photo closes.
|
||||
// Only `popstate` is intercepted — an in-app link or a programmatic `goto` should still
|
||||
// navigate, closing the modal with the page.
|
||||
beforeNavigate((nav) => {
|
||||
if (nav.type === 'popstate') {
|
||||
nav.cancel();
|
||||
onclose();
|
||||
}
|
||||
});
|
||||
|
||||
let comments = $state<CommentDto[]>([]);
|
||||
let newComment = $state('');
|
||||
@@ -56,6 +90,115 @@
|
||||
: pickMediaUrl($dataMode, upload)
|
||||
);
|
||||
|
||||
// ── Prev / next ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Until now the only way out of a photo was to close the modal, so browsing hundreds
|
||||
// of party photos meant open-close-open-close all evening — while FEATURES.md and
|
||||
// USER_JOURNEYS §8.6/§17 both promised swipe navigation. Three affordances, because
|
||||
// each covers a different user: on-screen chevrons are the discoverable baseline (a
|
||||
// swipe nobody knows about is not a feature), arrow keys for the projector laptop,
|
||||
// swipe for the phone in one hand.
|
||||
let prevBtn = $state<HTMLButtonElement | undefined>();
|
||||
let nextBtn = $state<HTMLButtonElement | undefined>();
|
||||
let closeBtn = $state<HTMLButtonElement | undefined>();
|
||||
|
||||
async function step(dir: -1 | 1) {
|
||||
if (dir === -1) {
|
||||
if (!hasPrev) return;
|
||||
onprev?.();
|
||||
} else {
|
||||
if (!hasNext) return;
|
||||
onnext?.();
|
||||
}
|
||||
// Stepping onto the first/last item removes the chevron that was just used. A
|
||||
// focused node disappearing drops focus to <body> — outside `focusTrap`'s node — so
|
||||
// the next Tab would leave for the browser chrome instead of cycling the modal.
|
||||
// Hand focus to whichever control survived.
|
||||
await tick();
|
||||
if (document.activeElement === document.body) {
|
||||
const fallback = dir === -1 ? nextBtn : prevBtn;
|
||||
(fallback ?? closeBtn)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
// Never hijack caret movement inside the comment composer.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||
e.preventDefault();
|
||||
void step(e.key === 'ArrowLeft' ? -1 : 1);
|
||||
}
|
||||
|
||||
// Swipe. Bound to the media wrapper rather than the whole modal so it cannot fight the
|
||||
// comment list's vertical scroll, and read on touchend rather than continuously so the
|
||||
// browser keeps ownership of the gesture until we know it was horizontal.
|
||||
const SWIPE_MIN_PX = 50;
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchTracking = false;
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
// A second finger means pinch-zoom, which must not turn into a page change.
|
||||
touchTracking = e.touches.length === 1;
|
||||
if (!touchTracking) return;
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
}
|
||||
|
||||
function onTouchEnd(e: TouchEvent) {
|
||||
if (!touchTracking) return;
|
||||
touchTracking = false;
|
||||
const t = e.changedTouches[0];
|
||||
if (!t) return;
|
||||
const dx = t.clientX - touchStartX;
|
||||
const dy = t.clientY - touchStartY;
|
||||
// Require a decisively HORIZONTAL gesture: the modal body scrolls vertically, so a
|
||||
// diagonal drag must not steal a photo change from someone who meant to scroll.
|
||||
if (Math.abs(dx) < SWIPE_MIN_PX || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||
void step(dx < 0 ? 1 : -1);
|
||||
}
|
||||
|
||||
// ── Media fallback ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A failed <img> used to render as an empty black box with `alt=""` — no icon, no
|
||||
// message, nothing to tap. And the failure is common rather than exotic: `pickMediaUrl`
|
||||
// falls back to `/original` whenever preview AND thumbnail are still null, i.e. for
|
||||
// everything still compressing, which in a newest-first feed is the top of the list
|
||||
// during every burst. That also makes the cause usually TRANSIENT, so one delayed retry
|
||||
// of the same URL recovers most of them; the nonce is there because the browser would
|
||||
// otherwise replay its cached failure rather than re-request.
|
||||
const MEDIA_RETRY_MS = 4000;
|
||||
let mediaRetryNonce = $state(0);
|
||||
let mediaFailed = $state(false);
|
||||
let mediaRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const displaySrc = $derived(
|
||||
mediaRetryNonce
|
||||
? `${mediaSrc}${mediaSrc.includes('?') ? '&' : '?'}r=${mediaRetryNonce}`
|
||||
: mediaSrc
|
||||
);
|
||||
|
||||
// A different photo — or the same one after `upload-processed` swapped the original for
|
||||
// a real preview — deserves a clean attempt rather than inheriting the old verdict.
|
||||
$effect(() => {
|
||||
void mediaSrc;
|
||||
mediaRetryNonce = 0;
|
||||
mediaFailed = false;
|
||||
});
|
||||
|
||||
function handleMediaError() {
|
||||
if (mediaRetryNonce) {
|
||||
mediaFailed = true;
|
||||
return;
|
||||
}
|
||||
if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
|
||||
mediaRetryTimer = setTimeout(() => {
|
||||
mediaRetryTimer = null;
|
||||
mediaRetryNonce = Date.now();
|
||||
}, MEDIA_RETRY_MS);
|
||||
}
|
||||
|
||||
function triggerHeartBurst() {
|
||||
heartBurst = true;
|
||||
vibrate(10);
|
||||
@@ -90,6 +233,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
if (burstTimer) clearTimeout(burstTimer);
|
||||
if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
|
||||
unsubCommentDeleted();
|
||||
unsubNewComment();
|
||||
});
|
||||
@@ -128,16 +272,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `asHost` routes to the moderation endpoint. The guest route only ever deletes the
|
||||
* caller's OWN comment, and it refuses a banned author outright — so without this a
|
||||
* host who banned an abusive guest was left with the abuse still on screen and no way
|
||||
* to remove it, since the ban itself blocks the author's own delete.
|
||||
*/
|
||||
async function deleteComment(id: string, asHost: boolean) {
|
||||
// Comment deletion is permanent and had NO confirmation at all — from a ~14px ✕ glyph
|
||||
// sitting a few pixels from the comment text, which a host could also use on someone
|
||||
// else's words. Deleting a POST two components away has always gone through ConfirmSheet;
|
||||
// this is the same irreversible act and now takes the same route.
|
||||
// `asHost` routes to the moderation endpoint: the guest route only ever deletes the
|
||||
// caller's OWN comment, and it refuses a banned author outright — so without it a host
|
||||
// who banned an abusive guest was left with the abuse on screen and no way to remove it,
|
||||
// since the ban itself blocks the author's own delete.
|
||||
let pendingCommentDelete = $state<{ id: string; asHost: boolean } | null>(null);
|
||||
|
||||
async function confirmDeleteComment() {
|
||||
const pending = pendingCommentDelete;
|
||||
if (!pending) return;
|
||||
pendingCommentDelete = null;
|
||||
try {
|
||||
await api.delete(asHost ? `/host/comment/${id}` : `/comment/${id}`);
|
||||
comments = comments.filter((c) => c.id !== id);
|
||||
await api.delete(pending.asHost ? `/host/comment/${pending.id}` : `/comment/${pending.id}`);
|
||||
comments = comments.filter((c) => c.id !== pending.id);
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
@@ -157,6 +308,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
role="dialog"
|
||||
@@ -166,12 +319,26 @@
|
||||
use:scrollLock
|
||||
use:modalInert
|
||||
>
|
||||
<!-- Backdrop — real <button> so keyboard / switch-control users get parity, matching
|
||||
ContextSheet / ConfirmSheet / UploadSheet. The lightbox was the one overlay in the app
|
||||
that did NOT close on an outside tap, which is why it felt broken: every other sheet
|
||||
does. `tabindex="-1"` keeps it out of the tab order, so it adds no stop in front of the
|
||||
photo; Escape still closes via `focusTrap` and the labelled ✕ stays the keyboard route.
|
||||
It sits behind the content, which is `relative` for exactly that reason. -->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 cursor-default"
|
||||
onclick={onclose}
|
||||
tabindex="-1"
|
||||
aria-label="Schließen"
|
||||
></button>
|
||||
<div
|
||||
class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900"
|
||||
class="relative flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900"
|
||||
>
|
||||
<!-- Media -->
|
||||
<div class="relative bg-black">
|
||||
<button
|
||||
bind:this={closeBtn}
|
||||
onclick={onclose}
|
||||
aria-label="Schließen"
|
||||
class="absolute right-2 top-2 z-10 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
||||
@@ -185,7 +352,59 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="relative" use:doubletap ondoubletap={triggerHeartBurst}>
|
||||
|
||||
<!-- Chevrons sit OUTSIDE the doubletap wrapper below, so tapping one can never be
|
||||
read as half of a double-tap and fire a like on the photo you are leaving. -->
|
||||
{#if hasPrev}
|
||||
<button
|
||||
bind:this={prevBtn}
|
||||
type="button"
|
||||
onclick={() => void step(-1)}
|
||||
aria-label="Vorheriges Foto"
|
||||
class="absolute left-2 top-1/2 z-10 inline-flex min-h-11 min-w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.75 19.5 8.25 12l7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if hasNext}
|
||||
<button
|
||||
bind:this={nextBtn}
|
||||
type="button"
|
||||
onclick={() => void step(1)}
|
||||
aria-label="Nächstes Foto"
|
||||
class="absolute right-2 top-1/2 z-10 inline-flex min-h-11 min-w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- `role="group"` is here for the touch handlers, not for semantics: swipe is a
|
||||
redundant shortcut for the labelled chevrons above and the arrow keys, so
|
||||
assistive tech loses nothing by treating this as a plain grouping. -->
|
||||
<div
|
||||
class="relative"
|
||||
role="group"
|
||||
aria-label="Foto – wischen zum Blättern"
|
||||
use:doubletap
|
||||
ondoubletap={triggerHeartBurst}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchend={onTouchEnd}
|
||||
ontouchcancel={() => (touchTracking = false)}
|
||||
>
|
||||
{#if isVideo(upload.mime_type)}
|
||||
<video
|
||||
src={mediaSrc}
|
||||
@@ -195,12 +414,29 @@
|
||||
class="max-h-[60vh] w-full object-contain"
|
||||
poster={upload.thumbnail_url ?? undefined}
|
||||
></video>
|
||||
{:else if mediaFailed}
|
||||
<!-- Both attempts failed. Anything is better than the empty black box this used
|
||||
to leave behind, which read as a broken app rather than a missing file. -->
|
||||
<div
|
||||
class="flex h-[40vh] flex-col items-center justify-center gap-2 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<svg class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm">Bild konnte nicht geladen werden.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
src={mediaSrc}
|
||||
src={displaySrc}
|
||||
alt=""
|
||||
class="max-h-[60vh] w-full object-contain select-none"
|
||||
class="max-h-[60vh] w-full select-none object-contain"
|
||||
draggable="false"
|
||||
onerror={handleMediaError}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -211,18 +447,20 @@
|
||||
<!-- Info + Comments -->
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<div class="border-b border-gray-100 p-3 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span id="lightbox-title" class="font-medium text-gray-900 dark:text-gray-100"
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<!-- `min-w-0` + `truncate`: without them a long display name (guests type their
|
||||
own) is unshrinkable and pushes the like button off the right edge. -->
|
||||
<div class="flex min-w-0 items-baseline gap-2">
|
||||
<span id="lightbox-title" class="truncate font-medium text-gray-900 dark:text-gray-100"
|
||||
>{upload.uploader_name}</span
|
||||
>
|
||||
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500"
|
||||
<span class="shrink-0 text-xs text-gray-400 dark:text-gray-500"
|
||||
>{formatTime(upload.created_at)}</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => onlike(upload.id)}
|
||||
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {upload.liked_by_me
|
||||
class="flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {upload.liked_by_me
|
||||
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
|
||||
>
|
||||
@@ -269,22 +507,32 @@
|
||||
</div>
|
||||
</div>
|
||||
{#if comment.user_id === userId || $isStaff}
|
||||
<!-- A trash glyph, not a ✕ (which reads as "dismiss"), and a real 44px tap
|
||||
target: the old 14px icon sat ~4px from the comment text, so a thumb
|
||||
aiming at the text destroyed the comment instead. -->
|
||||
<button
|
||||
onclick={() => deleteComment(comment.id, comment.user_id !== userId)}
|
||||
class="shrink-0 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||
aria-label={comment.user_id === userId ? 'Löschen' : 'Kommentar entfernen'}
|
||||
type="button"
|
||||
onclick={() =>
|
||||
(pendingCommentDelete = {
|
||||
id: comment.id,
|
||||
asHost: comment.user_id !== userId
|
||||
})}
|
||||
class="-m-1.5 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||
aria-label={comment.user_id === userId
|
||||
? 'Kommentar löschen'
|
||||
: 'Kommentar entfernen'}
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -335,3 +583,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Same branded confirmation the post-delete flow uses. -->
|
||||
<ConfirmSheet
|
||||
open={pendingCommentDelete !== null}
|
||||
title={pendingCommentDelete?.asHost ? 'Kommentar entfernen?' : 'Kommentar löschen?'}
|
||||
message={pendingCommentDelete?.asHost
|
||||
? 'Der Kommentar verschwindet für alle Gäste. Diese Aktion kann nicht rückgängig gemacht werden.'
|
||||
: 'Diese Aktion kann nicht rückgängig gemacht werden.'}
|
||||
confirmLabel={pendingCommentDelete?.asHost ? 'Entfernen' : 'Löschen'}
|
||||
tone="danger"
|
||||
onConfirm={confirmDeleteComment}
|
||||
onCancel={() => (pendingCommentDelete = null)}
|
||||
/>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { FeedUpload } from '$lib/types';
|
||||
import { dataMode } from '$lib/data-mode-store';
|
||||
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
|
||||
import { commentsEnabled } from '$lib/event-config-store';
|
||||
import { longpress } from '$lib/actions/longpress';
|
||||
import FeedListCard from './FeedListCard.svelte';
|
||||
@@ -62,12 +62,74 @@
|
||||
return mime.startsWith('video/');
|
||||
}
|
||||
|
||||
// Grid tiles always use the small thumbnail — full media is one tap away in the
|
||||
// lightbox where the data-mode picker decides for real.
|
||||
// Grid tiles prefer the SMALLEST derivative — three across, so a thumbnail is plenty and
|
||||
// the full file is one tap away in the lightbox.
|
||||
//
|
||||
// Everything past that preference defers to `pickMediaUrl`, the centralised rule the card
|
||||
// and the lightbox already follow (saver → preview → thumbnail → original). This used to
|
||||
// return '' when no derivative existed yet, which rendered the grey "broken image" tile
|
||||
// for every upload still in the compression queue. Because the feed is newest-first, that
|
||||
// was precisely the top of the grid during a burst — the photos people had just taken —
|
||||
// while the same items opened fine in the lightbox, which has always had the fallback.
|
||||
// An upload with no derivatives is a TRANSIENT state (SSE `upload-processed` swaps in the
|
||||
// preview as soon as the worker finishes), so the original is only ever fetched for the
|
||||
// short window before compression catches up.
|
||||
function tileUrl(upload: FeedUpload): string {
|
||||
if (upload.thumbnail_url) return upload.thumbnail_url;
|
||||
if (upload.preview_url) return upload.preview_url;
|
||||
return $dataMode === 'original' ? `/api/v1/upload/${upload.id}/original` : '';
|
||||
// VIDEOS: a poster image or nothing. `pickMediaUrl` is deliberately mime-agnostic and
|
||||
// bottoms out at `/original` — for a video that is the raw MP4, and this is an <img>.
|
||||
// Videos also never get a `preview_url` (compression.rs gives them a thumbnail only,
|
||||
// and only when a poster frame was actually extracted), so falling through would put a
|
||||
// multi-hundred-MB file behind three broken-image tiles per row. Returning '' keeps the
|
||||
// `{#if tileUrl(...)}` guard meaningful and leaves the play icon on its own. This is the
|
||||
// same carve-out LightboxModal documents for the same reason.
|
||||
if (isVideo(upload.mime_type)) return upload.thumbnail_url ?? '';
|
||||
if ($dataMode === 'saver' && upload.thumbnail_url) return upload.thumbnail_url;
|
||||
return pickMediaUrl($dataMode, upload);
|
||||
}
|
||||
|
||||
// ── Tile media fallback ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// A tile whose <img> 404s used to render as an empty grey box with `alt=""` — no icon,
|
||||
// no message, nothing to tap. And it is not an exotic case: `tileUrl` bottoms out at
|
||||
// `/original` while an upload is still compressing, which in a newest-first grid is the
|
||||
// top-left corner during every burst. That also makes the cause usually TRANSIENT, so
|
||||
// one delayed retry recovers most of them; the nonce is required because the browser
|
||||
// would otherwise replay its cached failure instead of re-requesting.
|
||||
//
|
||||
// Keyed by upload id rather than per-<img>, because the virtualizer recycles rows: a
|
||||
// tile that scrolls out and back must not restart the whole dance, and must not lose a
|
||||
// verdict we already reached. The recorded `url` is what makes that safe to keep — an
|
||||
// `upload-processed` that swaps the original for a real preview is a DIFFERENT url and
|
||||
// gets a clean attempt.
|
||||
const MEDIA_RETRY_MS = 4000;
|
||||
type TileError = { url: string; phase: 'retrying' | 'failed'; nonce: number };
|
||||
let tileErrors = $state<Record<string, TileError>>({});
|
||||
|
||||
function tileSrc(upload: FeedUpload): string {
|
||||
const base = tileUrl(upload);
|
||||
if (!base) return '';
|
||||
const e = tileErrors[upload.id];
|
||||
if (!e || e.url !== base) return base;
|
||||
if (e.phase === 'failed') return '';
|
||||
return e.nonce ? `${base}${base.includes('?') ? '&' : '?'}r=${e.nonce}` : base;
|
||||
}
|
||||
|
||||
function handleTileError(upload: FeedUpload) {
|
||||
const base = tileUrl(upload);
|
||||
if (!base) return;
|
||||
const e = tileErrors[upload.id];
|
||||
if (e && e.url === base) {
|
||||
// The retry failed too — show the placeholder rather than a blank tile.
|
||||
tileErrors[upload.id] = { ...e, phase: 'failed' };
|
||||
return;
|
||||
}
|
||||
tileErrors[upload.id] = { url: base, phase: 'retrying', nonce: 0 };
|
||||
setTimeout(() => {
|
||||
const cur = tileErrors[upload.id];
|
||||
if (cur && cur.url === base && cur.phase === 'retrying') {
|
||||
tileErrors[upload.id] = { ...cur, nonce: Date.now() };
|
||||
}
|
||||
}, MEDIA_RETRY_MS);
|
||||
}
|
||||
|
||||
// STABLE option callbacks — created once, never swapped. They read the live
|
||||
@@ -208,13 +270,14 @@
|
||||
>
|
||||
{#if isVideo(upload.mime_type)}
|
||||
<div class="flex h-full items-center justify-center bg-gray-800">
|
||||
{#if tileUrl(upload)}
|
||||
{#if tileSrc(upload)}
|
||||
<img
|
||||
src={tileUrl(upload)}
|
||||
src={tileSrc(upload)}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror={() => handleTileError(upload)}
|
||||
/>
|
||||
{/if}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
@@ -227,13 +290,14 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{:else if tileUrl(upload)}
|
||||
{:else if tileSrc(upload)}
|
||||
<img
|
||||
src={tileUrl(upload)}
|
||||
src={tileSrc(upload)}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror={() => handleTileError(upload)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center text-gray-400">
|
||||
|
||||
Reference in New Issue
Block a user