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:
Fabian Hamm (Privat)
2026-08-03 18:36:05 +02:00
parent 87d01a8a26
commit 51e55b1ace
11 changed files with 1025 additions and 243 deletions

View File

@@ -3,7 +3,13 @@
import { getToken, getUserId } from '$lib/auth';
import { isStaff } from '$lib/role-store';
import { api } from '$lib/api';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import {
connectSse,
disconnectSse,
onSseEvent,
startStreamBackstop,
stopStreamBackstop
} from '$lib/sse';
import { onMount, onDestroy } from 'svelte';
import VirtualFeed from '$lib/components/VirtualFeed.svelte';
import HashtagChips from '$lib/components/HashtagChips.svelte';
@@ -17,7 +23,6 @@
import { toast, toastError } from '$lib/toast-store';
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
import { vibrate } from '$lib/haptics';
import { filterUploads } from '$lib/feed-filter';
import { refreshEventState } from '$lib/event-state-store';
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
@@ -27,6 +32,11 @@
let nextCursor = $state<string | null>(null);
let loadingMore = $state(false);
let initialLoading = $state(true);
// Set when a load left us with NOTHING to show. Without it the template fell straight
// through to "Noch keine Fotos" — so on the venue WiFi the most likely first thing a
// guest ever saw was the app confidently telling them the gallery was empty, with no
// error, no retry, and nobody around to ask.
let loadError = $state(false);
let refreshing = $state(false);
let pullProgress = $state(0); // 01+ during the drag, 0 when idle
let selectedUpload = $state<FeedUpload | null>(null);
@@ -36,6 +46,10 @@
let sentinel: HTMLDivElement;
let feedObserver: IntersectionObserver | null = null;
let inPlaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
// Latest moment the coalescing window below is allowed to push the reconcile to, and
// a "an event arrived while hidden" flag — see `scheduleInPlaceRefresh`.
let inPlaceRefreshDeadline = 0;
let inPlaceRefreshDeferred = false;
// `asHost` picks the endpoint AND the copy: removing someone else's photo is a
// moderation action, not "delete my post", and it hits the host route.
let pendingDelete = $state<{ id: string; asHost: boolean } | null>(null);
@@ -49,11 +63,20 @@
if (typeof document === 'undefined') return;
const prev = document.documentElement.style.overscrollBehaviorY;
document.documentElement.style.overscrollBehaviorY = 'contain';
document.addEventListener('visibilitychange', handleVisibility);
return () => {
document.documentElement.style.overscrollBehaviorY = prev;
document.removeEventListener('visibilitychange', handleVisibility);
};
});
/** Run the reconcile that was deferred while the tab was hidden (see `scheduleInPlaceRefresh`). */
function handleVisibility() {
if (document.hidden || !inPlaceRefreshDeferred) return;
inPlaceRefreshDeferred = false;
scheduleInPlaceRefresh();
}
// View mode
let viewMode = $state<'list' | 'grid'>('list');
@@ -132,20 +155,42 @@
}
}
// ── Autocomplete derived from loaded uploads (no extra API calls) ────────
// ── Autocomplete sources ─────────────────────────────────────────────────
// Tags come from the SERVER's hashtag index (`/hashtags`, already loaded for the list
// view's chips and ordered by count) — not just the captions of the uploads currently
// in memory. Page 1 is 20 items, so deriving tags only from loaded captions meant a tag
// used further down the feed was simply absent from the picker until the user happened
// to scroll past the photo carrying it. That is the "sometimes I can't select a tag"
// case: the tag was never offered, so no amount of typing surfaced it.
//
// Captions of loaded uploads are unioned in afterwards so a tag from a photo that landed
// since the last `/hashtags` refresh is still offered. The regex mirrors the backend rule
// exactly — ASCII alphanumerics and `_`, stopping at the first other character (see
// backend `models/hashtag.rs::extract_hashtags`) — so both sources agree on what a tag is.
let allTags = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local throwaway counter inside a $derived.by; never stored in $state, so no reactivity is involved.
const freq = new Map<string, number>();
for (const u of uploads) {
for (const m of (u.caption ?? '').matchAll(/#(\w+)/g)) {
const t = m[1].toLowerCase();
freq.set(t, (freq.get(t) ?? 0) + 1);
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local dedupe set inside a $derived.by; never stored in $state, so no reactivity is involved.
const seen = new Set<string>();
const out: string[] = [];
const add = (raw: string) => {
const t = raw.toLowerCase();
if (t && !seen.has(t)) {
seen.add(t);
out.push(t);
}
};
for (const h of hashtags) add(h.tag);
for (const u of uploads) {
for (const m of (u.caption ?? '').matchAll(/#(\w+)/g)) add(m[1]);
}
return [...freq.entries()].sort((a, b) => b[1] - a[1]).map(([t]) => t);
return out;
});
let allUploaders = $derived([...new Set(uploads.map((u) => u.uploader_name))].sort());
// Uploaders come from `/uploaders` for the same reason tags come from `/hashtags`: deriving
// them from the uploads currently in memory meant typing a guest's name found nothing
// whenever their photos sat below page 1, which reads as "search is broken". The endpoint
// reads v_feed, so banned and hidden uploaders are already excluded.
let uploaderNames = $state<string[]>([]);
let allUploaders = $derived(uploaderNames);
// The suggestion SOURCE is frozen for as long as the dropdown is open.
//
@@ -186,31 +231,36 @@
...frozenTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t }))
];
}
// Once the user has TYPED, every match is offered — no `.slice()`. The old caps (8 for
// a `#` query, 4 tags + 4 users otherwise) silently dropped matches, so a tag that
// existed and matched what was typed still could not be selected, with nothing on
// screen to say more existed. The dropdown scrolls instead (see `max-h-72` below),
// which bounds the UI without bounding the choices.
if (q.startsWith('#')) {
const prefix = q.slice(1).toLowerCase();
return frozenTags
.filter((t) => t.startsWith(prefix))
.slice(0, 8)
.map((t) => ({ type: 'tag' as const, value: t }));
}
const lower = q.toLowerCase();
return [
...frozenUploaders
.filter((u) => u.toLowerCase().includes(lower))
.slice(0, 4)
.map((u) => ({ type: 'user' as const, value: u })),
...frozenTags
.filter((t) => t.includes(lower))
.slice(0, 4)
.map((t) => ({ type: 'tag' as const, value: t }))
];
});
// ── Filtered uploads for grid view ───────────────────────────────────────
let displayUploads = $derived.by(() => {
if (viewMode === 'list' || activeFilters.length === 0) return uploads;
return filterUploads(uploads, activeFilters);
});
// `uploads` IS the filtered set — the server applied the filters (see `filterParams`), so
// there is nothing left to narrow client-side. The previous client-side pass could only
// ever see the pages already loaded, so a tag whose photos sat past page 1 rendered a
// near-empty grid that looked complete, and it matched a caption SUBSTRING rather than the
// tag itself (`#tanz` also matched `#tanzflaeche`) — so list and grid disagreed about the
// same chip. Kept as an alias so the template and the infinite-scroll sentinel read the
// same way in both views.
let displayUploads = $derived(uploads);
// ─────────────────────────────────────────────────────────────────────────
// onMount B — auth gate, recovery toast, data load, SSE subscriptions,
@@ -243,8 +293,12 @@
// state in the same request.
void refreshEventState();
await Promise.all([loadFeed(), loadHashtags()]);
await Promise.all([loadFeed(), loadHashtags(), loadUploaders()]);
connectSse();
// Nothing in this page refetches on a timer — every update path below hangs off an
// SSE event. The backstop is what keeps that from meaning "one bad socket and the
// gallery is frozen until you force-reload", which is not a thing a guest will do.
startStreamBackstop();
unsubscribers.push(
onSseEvent('new-upload', (data) => {
@@ -371,6 +425,7 @@
onDestroy(() => {
disconnectSse();
stopStreamBackstop();
for (const unsub of unsubscribers) unsub();
feedObserver?.disconnect();
if (inPlaceRefreshTimer) clearTimeout(inPlaceRefreshTimer);
@@ -396,64 +451,164 @@
}
}
// Debounced page-1 fetch that *merges* (updates existing cards in place, prepends
// genuinely new ones) rather than replacing the array — preserves scroll and any
// pages already loaded below the fold.
// Coalescing window for the SSE-driven reconcile. The old floor was `800 + random*2000`,
// which during a burst (a bulk upload fires one `upload-processed` PER FILE) meant
// roughly one feed query per client every ~2s. At 100 guests that walks straight into
// the backend's 60/min per-user feed limit — and `refreshFeedInPlace`'s bare `catch {}`
// swallowed the resulting 429s, so the feed simply stopped updating with nothing on
// screen to say why. A reconcile is a background nicety; seconds of latency cost the
// guest nothing, while the request budget is the thing that actually runs out.
const REFRESH_DEBOUNCE_MS = 8_000;
// Spread on top of the floor. A fixed delay would make every client that saw the same
// broadcast fetch in the same window — 100 feed queries landing together, on top of the
// reconnect burst that often triggered them. The spread makes it a ramp.
const REFRESH_SPREAD_MS = 7_000;
// Ceiling on the coalescing, so a party that never stops posting still reconciles.
const REFRESH_MAX_WAIT_MS = 30_000;
// How much of the loaded feed a reconcile re-reads, and at what page size. The server
// caps `limit` at 100.
const RECONCILE_PAGE = 100;
const RECONCILE_MAX_PAGES = 3;
// Debounced fetch that *merges* (updates existing cards in place, prepends genuinely
// new ones) rather than replacing the array — preserves scroll and any pages already
// loaded below the fold.
function scheduleInPlaceRefresh() {
if (inPlaceRefreshTimer) return;
// A hidden tab cannot show the result, and iOS clamps its timers into a clump that
// all fires at once on wake. Defer to the visibility change, where exactly one runs.
if (typeof document !== 'undefined' && document.hidden) {
inPlaceRefreshDeferred = true;
return;
}
const nowMs = Date.now();
if (!inPlaceRefreshTimer) inPlaceRefreshDeadline = nowMs + REFRESH_MAX_WAIT_MS;
// Push the reconcile out again on every further event, so a burst of thirty uploads
// costs ONE feed query once it settles rather than one per event — but never past
// the deadline above.
const delay = Math.min(
REFRESH_DEBOUNCE_MS + Math.random() * REFRESH_SPREAD_MS,
Math.max(0, inPlaceRefreshDeadline - nowMs)
);
if (inPlaceRefreshTimer) clearTimeout(inPlaceRefreshTimer);
inPlaceRefreshTimer = setTimeout(() => {
inPlaceRefreshTimer = null;
void refreshFeedInPlace();
}, 800);
void refreshFeedInPlace().catch(() => {
// Background reconcile — quiet by design. The next event, the next visibility
// change or a pull-to-refresh retries; if this was a 429 we are already over
// budget and retrying immediately is the worst possible response.
});
}, delay);
}
async function refreshFeedInPlace() {
try {
/**
* Merge the server's current view of the LOADED WINDOW into the in-memory list.
*
* Reconciling only page 1 (what this used to do) meant like/comment counts on items
* 21..N moved solely via live `like-update` / `new-comment` — so for a guest who had
* scrolled through a few hundred photos, everything that happened while their phone
* was asleep or their stream was down was lost permanently. Paging at the server's
* 100 cap and stopping after `RECONCILE_MAX_PAGES` keeps that at 13 requests instead
* of one per 20 items, which at a party's event rate would be its own little DDoS.
*
* Throws: callers decide whether the failure is worth showing (the pill's tap is, a
* background event is not).
*/
async function refreshFeedInPlace(): Promise<void> {
const base = filterParams();
base.set('limit', String(RECONCILE_PAGE));
const known = new Set(uploads.map((u) => u.id));
// How much of the loaded window we set out to re-read for fresh counts.
const windowPages = Math.min(
RECONCILE_MAX_PAGES,
Math.max(1, Math.ceil(uploads.length / RECONCILE_PAGE))
);
const fetched: FeedUpload[] = [];
let cursor: string | null = null;
for (let page = 0; page < RECONCILE_MAX_PAGES; page++) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
const params = new URLSearchParams();
if (selectedHashtag) params.set('hashtag', selectedHashtag);
params.set('limit', '20');
const params = new URLSearchParams(base);
if (cursor) params.set('cursor', cursor);
const res = await api.get<FeedResponse>(`/feed?${params}`);
const byId = new Map(res.uploads.map((u) => [u.id, u]));
const known = new Set(uploads.map((u) => u.id));
uploads = uploads.map((u) => byId.get(u.id) ?? u);
const fresh = res.uploads.filter((u) => !known.has(u.id));
if (fresh.length) uploads = [...fresh, ...uploads];
} catch {
// Background refresh — stay quiet, the next event or pull-to-refresh retries.
fetched.push(...res.uploads);
cursor = res.next_cursor;
if (!cursor) break;
// Nothing fetched so far overlaps what we hold, so the head we are prepending is
// not yet CONTIGUOUS with the old list — there are still unseen uploads in between.
// Keep paging until the two reconverge, or the merge would silently leave a hole
// in the middle of the feed (the truncated-delta pill is exactly this case: more
// than the backend's 200-row delta cap arrived while the guest was away).
const bridged = known.size === 0 || fetched.some((u) => known.has(u.id));
if (page + 1 >= windowPages && bridged) break;
}
const byId = new Map(fetched.map((u) => [u.id, u]));
uploads = uploads.map((u) => byId.get(u.id) ?? u);
const fresh = fetched.filter((u) => !known.has(u.id));
if (fresh.length) uploads = [...fresh, ...uploads];
}
/**
* The "Neue Beiträge" pill's action. MERGES page 1 into the head of what is already
* loaded instead of replacing the array with it.
*
* Replacing collapsed the virtualizer's total size from however many rows were loaded
* (400+ after an evening of scrolling) down to 20, so the browser dropped the reader at
* an arbitrary offset in a feed that had just shrunk under them — the exact yank the
* pill exists to prevent (see the `feedStale` comment at the top of this file).
* `nextCursor` is deliberately left alone for the same reason: the tail below the fold
* is still loaded and still paginating from where it was.
*/
async function refreshStale() {
try {
await refreshFeedInPlace();
feedStale = false;
} catch (e) {
// Keep the pill up so the tap can be retried. Clearing it before the request (as
// this used to) left a guest whose tap failed with no signal AND no control.
toastError(e);
}
}
async function loadFeed(refresh = false) {
// Any full refresh (pill tap, pull-to-refresh, filter change) resyncs page 1,
// so the "new posts" pill is no longer relevant — clear it here rather than
// only in the pill's own onclick, or a pull-to-refresh leaves it stranded.
if (refresh) feedStale = false;
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
const params = new URLSearchParams();
const params = filterParams();
if (!refresh && nextCursor) params.set('cursor', nextCursor);
if (selectedHashtag) params.set('hashtag', selectedHashtag);
params.set('limit', '20');
const res = await api.get<FeedResponse>(`/feed?${params}`);
uploads = res.uploads;
nextCursor = res.next_cursor;
loadError = false;
// A full refresh (pull-to-refresh, filter change) has resynced page 1, so the
// "new posts" pill is no longer relevant. Cleared only on SUCCESS: clearing it up
// front meant a failed pull-to-refresh silently ate the one affordance the guest
// had for getting the new photos.
if (refresh) feedStale = false;
} catch (e) {
// Initial / user-triggered refresh is worth surfacing — background SSE refetches are noisier and silenced below.
if (!refresh) toastError(e);
// Every path through here is user-triggered (first load, pull-to-refresh, filter
// change) — silencing the refresh ones, as this used to, made pull-to-refresh fail
// completely invisibly.
toastError(e);
// Only the case where we have nothing left on screen earns the full error view;
// a failed refresh over an already-populated feed keeps the feed.
if (uploads.length === 0) loadError = true;
} finally {
initialLoading = false;
}
}
/** "Erneut laden" from the error state — a clean re-run of everything onMount loads. */
async function retryInitialLoad() {
loadError = false;
initialLoading = true;
nextCursor = null;
await Promise.all([loadFeed(true), loadHashtags(), loadUploaders()]);
}
async function loadMore() {
if (!nextCursor || loadingMore) return;
loadingMore = true;
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
const params = new URLSearchParams();
const params = filterParams();
params.set('cursor', nextCursor);
if (selectedHashtag) params.set('hashtag', selectedHashtag);
params.set('limit', '20');
const res = await api.get<FeedResponse>(`/feed?${params}`);
uploads = [...uploads, ...res.uploads];
@@ -473,6 +628,14 @@
}
}
async function loadUploaders() {
try {
uploaderNames = await api.get<string[]>('/uploaders');
} catch {
// Same as the hashtag index: the picker degrades, the feed keeps working.
}
}
async function pullRefresh() {
if (refreshing) return;
refreshing = true;
@@ -480,16 +643,69 @@
vibrate(10);
try {
nextCursor = null;
await Promise.all([loadFeed(true), loadHashtags()]);
await Promise.all([loadFeed(true), loadHashtags(), loadUploaders()]);
} finally {
refreshing = false;
}
}
// ── Filter state ─────────────────────────────────────────────────────────
//
// BOTH views filter server-side now; the two states below are just the two UIs for it.
// `selectedHashtag` is the list's single chip, `activeFilters` the grid's chip row, and
// `filterParams()` is the one place either is turned into a request.
//
// Previously the list filtered server-side while the grid filtered the loaded array
// client-side, and the two never synced — so a tag picked in the list kept filtering the
// grid (the fetch still carried `?hashtag=`) while the grid's chip row rendered the empty
// `activeFilters`: active, invisible, unclearable. The grid also matched a caption
// substring rather than the tag, so `#tanz` matched `#tanzflaeche` in one view and not the
// other, and it could only ever see the pages already loaded.
//
// The helpers below keep the two chip UIs in sync when switching views.
/** The tag currently shown as a grid chip, if any. Grid supports several; list has one. */
function firstTagFilter(): string | null {
return activeFilters.find((f) => f.type === 'tag')?.value ?? null;
}
/**
* The active filter as query params — the SINGLE place that translates UI state into a
* request, used by every fetch path (initial load, pagination, pull-to-refresh, and the
* SSE-driven in-place refresh).
*
* Those four used to build their own params, and each only knew about `selectedHashtag` —
* so the grid's chips were never sent at all and were applied client-side over whatever
* pages happened to be loaded.
*/
function filterParams(): URLSearchParams {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
const params = new URLSearchParams();
if (viewMode === 'list') {
if (selectedHashtag) params.set('hashtag', selectedHashtag);
return params;
}
const tags = activeFilters.filter((f) => f.type === 'tag').map((f) => f.value);
if (tags.length) params.set('hashtags', tags.join(','));
const user = activeFilters.find((f) => f.type === 'user');
if (user) params.set('uploader', user.value);
return params;
}
/** Re-run page 1 under the current filters. Any filter change resets pagination. */
function reloadForFilters() {
nextCursor = null;
loadFeed(true);
}
function selectHashtag(tag: string | null) {
selectedHashtag = tag;
nextCursor = null;
loadFeed();
// Mirror into the grid chips so switching views SHOWS the active filter. Tag chips are
// replaced rather than appended: the list's model is one tag. User chips are grid-only,
// so they survive untouched.
const users = activeFilters.filter((f) => f.type === 'user');
activeFilters = tag ? [{ type: 'tag', value: tag }, ...users] : users;
reloadForFilters();
}
async function handleLike(id: string) {
@@ -527,28 +743,85 @@
}
function selectSuggestion(item: Filter) {
if (!activeFilters.some((f) => f.type === item.type && f.value === item.value)) {
if (item.type === 'user') {
// Exactly ONE uploader at a time. The server takes a single `uploader` param
// rather than a list because display names may contain a comma, and a CSV would
// silently split such a name into two filters that match nobody. Tags are safe to
// CSV — the backend restricts them to ASCII alphanumerics and `_`.
activeFilters = [...activeFilters.filter((f) => f.type !== 'user'), item];
} else if (!activeFilters.some((f) => f.type === item.type && f.value === item.value)) {
activeFilters = [...activeFilters, item];
}
searchQuery = '';
showAutocomplete = false;
reloadForFilters();
}
function removeFilter(item: Filter) {
activeFilters = activeFilters.filter((f) => !(f.type === item.type && f.value === item.value));
reloadForFilters();
}
// `selectedHashtag` is cleared too, not just the grid chips: the two are mirrors of one
// server-side filter (see `selectHashtag`), so dropping only `activeFilters` left the
// list's tag armed and invisible — switching back to the list silently re-applied it.
function clearFilters() {
activeFilters = [];
selectedHashtag = null;
searchQuery = '';
reloadForFilters();
}
/**
* Whether the empty feed we are looking at is "no matches" rather than "no photos yet".
* Both chip UIs count, because the list and the grid express the same server-side
* filter differently.
*/
const hasActiveFilters = $derived(
viewMode === 'list' ? selectedHashtag !== null : activeFilters.length > 0
);
// ── Lightbox stepping ────────────────────────────────────────────────────────────
//
// Prev/next walk THE SAME array the feed renders — already server-filtered and
// server-ordered — so "next" in the lightbox is the photo the guest would have
// scrolled to. It stops at the end of what is LOADED rather than paging: infinite
// scroll owns pagination, and having the modal extend the list would grow the
// virtualizer behind it while it is the thing holding focus.
const lightboxIndex = $derived(
selectedUpload ? uploads.findIndex((u) => u.id === selectedUpload!.id) : -1
);
function stepLightbox(delta: -1 | 1) {
if (lightboxIndex < 0) return;
const next = uploads[lightboxIndex + delta];
if (next) selectedUpload = next;
}
function switchView(mode: 'list' | 'grid') {
viewMode = mode;
if (mode === 'list') {
const before = filterParams().toString();
if (mode === 'grid') {
// Carry the list's tag into the grid's chip row so it is visible — and therefore
// removable — there.
if (
selectedHashtag &&
!activeFilters.some((f) => f.type === 'tag' && f.value === selectedHashtag)
) {
activeFilters = [{ type: 'tag', value: selectedHashtag }, ...activeFilters];
}
} else {
searchQuery = '';
showAutocomplete = false;
// The list expresses exactly one tag, so carry the first chip across. A second tag
// or an uploader chip cannot be represented here; they stay on `activeFilters` and
// come back when the user returns to the grid.
selectedHashtag = firstTagFilter();
}
viewMode = mode;
// Both views send their filters to the server, but they express them differently
// (`hashtag` vs `hashtags`+`uploader`), so refetch only when the effective query
// actually changed — switching views with no filter must not reset pagination.
if (filterParams().toString() !== before) reloadForFilters();
}
</script>
@@ -604,10 +877,7 @@
<button
type="button"
class="pointer-events-auto rounded-full border border-primary-400 bg-primary-100 px-4 py-1.5 text-xs font-semibold text-primary-800 shadow-lg hover:border-primary-500 hover:bg-primary-200 dark:border-primary-500/50 dark:bg-primary-950/60 dark:text-primary-200"
onclick={() => {
feedStale = false;
void loadFeed(true);
}}
onclick={() => void refreshStale()}
>
Neue Beiträge tippen zum Aktualisieren
</button>
@@ -781,7 +1051,7 @@
element self-destructing is harmless, and press-then-slide-off aborts like a button
should. -->
<div
class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
class="absolute left-0 right-0 top-full z-50 mt-1 max-h-72 overflow-y-auto overscroll-contain rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
onmousedown={(e) => e.preventDefault()}
role="listbox"
tabindex="-1"
@@ -918,10 +1188,51 @@
</div>
{/if}
</div>
{:else if loadError}
<!-- Must sit AHEAD of the empty state: a failed load also leaves `uploads` empty, and
"Noch keine Fotos" is then an outright lie — the one thing a guest on a congested
venue WiFi must not be told, since there is no operator to correct it. -->
<div class="py-20 text-center" data-testid="feed-error">
<svg
class="mx-auto mb-3 h-12 w-12 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
/>
</svg>
<p class="text-lg font-medium text-gray-700 dark:text-gray-300">
Galerie konnte nicht geladen werden.
</p>
<p class="mx-auto mt-1 max-w-xs text-sm text-gray-500 dark:text-gray-400">
Das WLAN ist gerade voll. Versuch es gleich noch einmal.
</p>
<button onclick={() => void retryInitialLoad()} class="btn btn-primary btn-sm mt-4">
Erneut laden
</button>
</div>
{:else if uploads.length === 0 && hasActiveFilters}
<!-- "No matches" is checked BEFORE "no photos": since filtering moved server-side,
an empty response under an active filter is exactly that, and the generic empty
state told a guest who had just tapped a hashtag chip to go take a photo. -->
<div class="py-16 text-center" data-testid="feed-no-matches">
<p class="text-sm text-gray-400 dark:text-gray-500">
Keine Treffer für die gewählten Filter.
</p>
<button onclick={clearFilters} class="btn btn-ghost btn-sm mt-2">Filter zurücksetzen</button>
</div>
{:else if uploads.length === 0}
<div class="py-20 text-center">
<p class="text-lg text-gray-400 dark:text-gray-500">Noch keine Fotos.</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Tippe auf den Plus-Button unten!</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">
Tippe auf den Kamera-Button unten!
</p>
</div>
{:else if viewMode === 'list'}
<!-- List view: chronological full-width cards (DOM-windowed) -->
@@ -937,33 +1248,21 @@
/>
</div>
{:else}
<!-- Grid view: 3-col, filters applied (DOM-windowed by row) -->
<!-- Grid view: 3-col, filters applied server-side (DOM-windowed by row). The
"keine Treffer" branch that used to live here was unreachable — `displayUploads`
is a plain alias of `uploads` since filtering moved server-side, so its emptiness
check was identical to the one two branches above, which always won. It now
lives up there, where it can actually be reached from either view. -->
<div class="mx-auto max-w-2xl">
{#if displayUploads.length === 0}
<div class="py-16 text-center">
<p class="text-sm text-gray-400 dark:text-gray-500">
Keine Treffer für die gewählten Filter.
</p>
{#if nextCursor}
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
Es sind noch nicht alle Beiträge geladen — scrolle weiter, um mehr zu durchsuchen.
</p>
{/if}
<button onclick={clearFilters} class="btn btn-ghost btn-sm mt-2"
>Filter zurücksetzen</button
>
</div>
{:else}
<VirtualFeed
mode="grid"
uploads={displayUploads}
{myUserId}
onlike={handleLike}
oncomment={openComments}
onselect={(u) => (selectedUpload = u)}
oncontextmenu={openContextSheet}
/>
{/if}
<VirtualFeed
mode="grid"
uploads={displayUploads}
{myUserId}
onlike={handleLike}
oncomment={openComments}
onselect={(u) => (selectedUpload = u)}
oncontextmenu={openContextSheet}
/>
</div>
{/if}
@@ -986,6 +1285,10 @@
upload={selectedUpload}
onclose={() => (selectedUpload = null)}
onlike={handleLike}
hasPrev={lightboxIndex > 0}
hasNext={lightboxIndex >= 0 && lightboxIndex < uploads.length - 1}
onprev={() => stepLightbox(-1)}
onnext={() => stepLightbox(1)}
/>
{/if}