Files
EventSnap/frontend/src/routes/feed/+page.svelte
Fabian Hamm (Privat) 51e55b1ace 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>
2026-08-03 18:36:05 +02:00

1317 lines
54 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getUserId } from '$lib/auth';
import { isStaff } from '$lib/role-store';
import { api } from '$lib/api';
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';
import LightboxModal from '$lib/components/LightboxModal.svelte';
import OnboardingGuide from '$lib/components/OnboardingGuide.svelte';
import ContextSheet, { type ContextAction } from '$lib/components/ContextSheet.svelte';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import { refreshQuota } from '$lib/quota-store';
import { exportStatus } from '$lib/export-status-store';
import { toast, toastError } from '$lib/toast-store';
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
import { vibrate } from '$lib/haptics';
import { refreshEventState } from '$lib/event-state-store';
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
let uploads = $state<FeedUpload[]>([]);
let hashtags = $state<HashtagCount[]>([]);
let selectedHashtag = $state<string | null>(null);
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);
// Set when a truncated feed-delta means we missed too much to merge — shows a
// tap-to-refresh pill instead of yanking the user's scroll to page 1.
let feedStale = $state(false);
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);
// ─────────────────────────────────────────────────────────────────────────
// onMount A — DOM side-effects only (overscroll lock). Synchronous, returns
// its own cleanup. Kept separate from the data-loading onMount below so its
// cleanup can't be accidentally clobbered when someone edits the async one.
// ─────────────────────────────────────────────────────────────────────────
onMount(() => {
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');
// Grid search / filter state
let searchQuery = $state('');
let showAutocomplete = $state(false);
interface Filter {
type: 'tag' | 'user';
value: string;
}
let activeFilters = $state<Filter[]>([]);
let unsubscribers: (() => void)[] = [];
// Long-press / context-sheet state for post actions
let contextTarget = $state<FeedUpload | null>(null);
const myUserId = getUserId();
const contextActions = $derived<ContextAction[]>(buildContextActions(contextTarget));
function buildContextActions(target: FeedUpload | null): ContextAction[] {
if (!target) return [];
const actions: ContextAction[] = [
{
label: 'Original anzeigen',
icon: '⤓',
onClick: () => {
window.open(`/api/v1/upload/${target.id}/original`, '_blank');
}
}
];
if (target.user_id === myUserId) {
actions.unshift({
label: 'Löschen',
icon: '🗑',
tone: 'danger',
onClick: () => {
pendingDelete = { id: target.id, asHost: false };
}
});
} else if ($isStaff) {
// Moderation. Without this the only lever a host had against an unwanted photo
// was banning the uploader — which is both disproportionate and ineffective,
// since a ban does not retract what they already posted.
actions.unshift({
label: 'Beitrag entfernen',
icon: '🚫',
tone: 'danger',
onClick: () => {
pendingDelete = { id: target.id, asHost: true };
}
});
}
return actions;
}
function openContextSheet(upload: FeedUpload) {
contextTarget = upload;
}
async function confirmDelete() {
const pending = pendingDelete;
if (!pending) return;
pendingDelete = null;
try {
// The guest route rejects anything the caller doesn't own, so a host removing
// someone else's photo must go through the host route. That one also emits the
// `upload-deleted` SSE and writes an audit-log entry.
await api.delete(pending.asHost ? `/host/upload/${pending.id}` : `/upload/${pending.id}`);
uploads = uploads.filter((u) => u.id !== pending.id);
if (selectedUpload?.id === pending.id) selectedUpload = null;
// Only our own delete frees our quota; a host removal refunds the uploader.
if (!pending.asHost) void refreshQuota();
} catch (e) {
toastError(e);
}
}
// ── 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 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 out;
});
// 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.
//
// `allTags`/`allUploaders` derive from `uploads`, which mutates under us constantly: every
// `upload-processed` / `new-upload` SSE triggers `refreshFeedInPlace`, which replaces the array.
// `allTags` is ordered by FREQUENCY, so a photo landing mid-interaction can REORDER the open
// dropdown — the user presses "#wedding" and the list reshuffles under their finger. At a party,
// where photos stream in the whole time, that is a live mis-tap hazard, not a theoretical one.
// (It also detaches the button mid-press: the handler is `onmousedown`, so a re-render between
// mousedown and mouseup destroys the element being pressed.)
//
// Freezing on focus keeps the list the user is looking at identical to the list they act on.
// Typing still filters — it just filters a stable source. New photos appear in the suggestions
// the next time the dropdown is opened, which is soon enough for a filter picker.
let frozenTags = $state<string[]>([]);
let frozenUploaders = $state<string[]>([]);
// Re-snapshot only when actually opening. Called on focus AND on click/input, because after a
// selection the input keeps focus (the dropdown suppresses the blur) — so `onfocus` will not
// fire again, and without these the picker could never be reopened without clicking away first.
// Guarding on `showAutocomplete` keeps typing from re-freezing on every keystroke, which would
// let the list churn again mid-interaction and undo the point of freezing it.
function openAutocomplete() {
if (!showAutocomplete) {
frozenTags = allTags;
frozenUploaders = allUploaders;
}
showAutocomplete = true;
}
let suggestions = $derived.by((): Filter[] => {
const q = searchQuery.trim();
if (!q) {
// Show top suggestions on focus
if (!showAutocomplete) return [];
return [
...frozenUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })),
...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))
.map((t) => ({ type: 'tag' as const, value: t }));
}
const lower = q.toLowerCase();
return [
...frozenUploaders
.filter((u) => u.toLowerCase().includes(lower))
.map((u) => ({ type: 'user' as const, value: u })),
...frozenTags
.filter((t) => t.includes(lower))
.map((t) => ({ type: 'tag' as const, value: t }))
];
});
// `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,
// infinite-scroll observer. Cleanup of the SSE handlers lives in onDestroy
// below (not in a returned cleanup) because this callback is async.
// ─────────────────────────────────────────────────────────────────────────
onMount(async () => {
if (!getToken()) {
goto('/join');
return;
}
// Surface the welcome-back toast set by /recover. Lives here (not on /account)
// because /feed is the first hydrated route after a successful PIN recovery —
// the toast should land in the same beat as the "you're in" feeling.
if (typeof sessionStorage !== 'undefined') {
const welcome = sessionStorage.getItem('eventsnap_just_recovered');
if (welcome) {
sessionStorage.removeItem('eventsnap_just_recovered');
toast(`Willkommen zurück, ${welcome}!`, 'success');
}
}
// Pull the authoritative role/event state. The root layout does this on a full page
// load, but arriving here from /join or /recover is a client-side navigation, so its
// onMount never re-runs — and the feed is the one route that gates a destructive
// action (host "Beitrag entfernen") on the role. Without this the feed would run on
// whatever the JWT claim said, which is frozen for the token's 30-day lifetime and so
// misses a promotion or demotion entirely. Cheap, and it refreshes the lock/release
// state in the same request.
void refreshEventState();
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) => {
try {
const upload: FeedUpload = JSON.parse(data);
// GRID view must NOT prepend live. Its rows are POSITIONAL windows
// (`uploads.slice(i * COLS, …)` in VirtualFeed), so inserting at the head shifts
// every tile by one slot: each row's keyed `{#each}` then sees a different set of
// ids and Svelte DESTROYS AND RECREATES the tile nodes. Two consequences at a
// party, where photos arrive continuously — a tap in flight is swallowed when its
// node is torn out, and the photo under the user's finger silently becomes a
// DIFFERENT photo, so they like or open one they never chose.
//
// The "neue Beiträge" pill already exists for exactly this: buffer, and let the
// user pull the new photos in when they are not mid-tap. List view is keyed by id
// at the top level and anchored, so its nodes survive a prepend — it stays live.
if (viewMode === 'grid') {
feedStale = true;
return;
}
uploads = [upload, ...uploads];
} catch {
/* ignore */
}
}),
// A processed upload gains preview/thumbnail URLs. Coalesce bursts (bulk
// uploads fire one per file) into a single in-place merge so the feed
// neither hammers the server nor collapses to page 1 / loses scroll.
onSseEvent('upload-processed', () => scheduleInPlaceRefresh()),
onSseEvent('upload-deleted', (data) => {
try {
const payload = JSON.parse(data) as { upload_id: string };
uploads = uploads.filter((u) => u.id !== payload.upload_id);
if (selectedUpload?.id === payload.upload_id) selectedUpload = null;
} catch {
/* ignore */
}
}),
// A background transcode failed: the backend already cleaned up (refunded
// quota, removed the row) and an upload-deleted evicts the card. Only the
// uploader gets a toast — registered before upload-deleted so the card is
// still present to check ownership.
onSseEvent('upload-error', (data) => {
try {
const { upload_id } = JSON.parse(data) as { upload_id: string };
const mine = uploads.find((u) => u.id === upload_id && u.user_id === myUserId);
if (mine) {
toast('Ein Upload konnte nicht verarbeitet werden.', 'error');
void refreshQuota();
}
} catch {
/* ignore */
}
}),
// A banned user's uploads were hidden — drop all their cards live.
onSseEvent('user-hidden', (data) => {
try {
const { user_id } = JSON.parse(data) as { user_id: string };
uploads = uploads.filter((u) => u.user_id !== user_id);
if (selectedUpload && selectedUpload.user_id === user_id) selectedUpload = null;
} catch {
/* ignore */
}
}),
// Patch the single affected card in place from the SSE payload instead of
// refetching page 1 — a busy event fires these constantly and a full reload
// would yank every scrolled-down user back to the top on each reaction.
onSseEvent('like-update', (data) => patchCount(data, 'like_count')),
onSseEvent('new-comment', (data) => patchCount(data, 'comment_count')),
// Synthetic event from the SSE client after a foreground reconnect — merge
// any uploads + deletions we missed while the tab was hidden.
onSseEvent('feed-delta', (data) => {
try {
const delta = JSON.parse(data) as DeltaResponse;
// Evict removed content FIRST and unconditionally — deletions AND ban-hides are
// explicit, uncapped lists, so they apply even on a truncated delta (the stale-pill
// refresh below only prunes page 1, so a banned user's cards beyond page 1 would
// otherwise linger). Replays a `user-hidden`/delete missed while the tab was hidden.
if (delta.deleted_ids.length) {
const dead = new Set(delta.deleted_ids);
uploads = uploads.filter((u) => !dead.has(u.id));
if (selectedUpload && dead.has(selectedUpload.id)) selectedUpload = null;
}
if (delta.hidden_user_ids.length) {
const hidden = new Set(delta.hidden_user_ids);
uploads = uploads.filter((u) => !hidden.has(u.user_id));
if (selectedUpload && hidden.has(selectedUpload.user_id)) selectedUpload = null;
}
if (delta.truncated) {
// Missed more than the backend delta cap while backgrounded — the
// delta is only the newest slice, so merging would leave a silent gap
// of older-but-still-new uploads. Resync from page 1 instead.
// Rather than involuntarily yank the user to page 1, surface a
// tap-to-refresh pill so they keep scroll until they resync.
feedStale = true;
return;
}
if (delta.uploads.length) {
const seen = new Set(uploads.map((u) => u.id));
const fresh = delta.uploads.filter((u) => !seen.has(u.id));
if (fresh.length) uploads = [...fresh, ...uploads];
}
// A delta reconciles new uploads and deletions, but not like/comment
// counts that changed on already-visible cards while we were
// disconnected or lagged (a `resync`). Debounced page-1 refresh merges
// those fresh counts in place without disturbing scroll.
scheduleInPlaceRefresh();
} catch {
/* ignore */
}
})
);
if (sentinel) {
feedObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && nextCursor && !loadingMore) loadMore();
},
{ rootMargin: '200px' }
);
feedObserver.observe(sentinel);
}
});
onDestroy(() => {
disconnectSse();
stopStreamBackstop();
for (const unsub of unsubscribers) unsub();
feedObserver?.disconnect();
if (inPlaceRefreshTimer) clearTimeout(inPlaceRefreshTimer);
});
// Patch a single upload's like/comment count from an SSE payload without
// disturbing scroll position or the rest of the loaded feed.
function patchCount(data: string, field: 'like_count' | 'comment_count') {
try {
const payload = JSON.parse(data) as {
upload_id: string;
like_count?: number;
comment_count?: number;
};
const value = payload[field];
if (value === undefined) return;
uploads = uploads.map((u) => (u.id === payload.upload_id ? { ...u, [field]: value } : u));
if (selectedUpload?.id === payload.upload_id) {
selectedUpload = { ...selectedUpload, [field]: value };
}
} catch {
/* ignore malformed payloads */
}
}
// 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() {
// 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().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);
}
/**
* 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(base);
if (cursor) params.set('cursor', cursor);
const res = await api.get<FeedResponse>(`/feed?${params}`);
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) {
try {
const params = filterParams();
if (!refresh && nextCursor) params.set('cursor', nextCursor);
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) {
// 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 {
const params = filterParams();
params.set('cursor', nextCursor);
params.set('limit', '20');
const res = await api.get<FeedResponse>(`/feed?${params}`);
uploads = [...uploads, ...res.uploads];
nextCursor = res.next_cursor;
} catch (e) {
toastError(e);
} finally {
loadingMore = false;
}
}
async function loadHashtags() {
try {
hashtags = await api.get<HashtagCount[]>('/hashtags');
} catch {
// Hashtag panel is a discoverability nicety — silent fail is acceptable; the feed still works.
}
}
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;
pullProgress = 0;
vibrate(10);
try {
nextCursor = null;
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;
// 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) {
try {
// Set state from the server's authoritative response rather than blind-inverting
// local state. On a second device (same recovered user), the `like-update`
// broadcast only carries `like_count` — so a blind invert would drift
// `liked_by_me` until refresh. The response gives both, exactly.
const res = await api.post<{ liked: boolean; like_count: number | null }>(
`/upload/${id}/like`
);
vibrate(10);
// like_count is null when the server's count query hiccuped — keep the current
// count in that case rather than adopting a wrong number.
uploads = uploads.map((u) =>
u.id === id
? { ...u, liked_by_me: res.liked, like_count: res.like_count ?? u.like_count }
: u
);
if (selectedUpload?.id === id) {
selectedUpload = {
...selectedUpload,
liked_by_me: res.liked,
like_count: res.like_count ?? selectedUpload.like_count
};
}
} catch (e) {
toastError(e);
}
}
function openComments(id: string) {
const u = uploads.find((u) => u.id === id);
if (u) selectedUpload = u;
}
function selectSuggestion(item: Filter) {
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') {
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>
<div
class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950"
use:pullToRefresh={{
onrefresh: pullRefresh,
onpull: (_, progress) => (pullProgress = progress),
disabled: initialLoading
}}
>
<!-- Live pull-progress indicator: grows during the drag, rotates past threshold,
swaps to a spinner once the network refresh kicks off. -->
{#if refreshing || pullProgress > 0}
<div
class="pointer-events-none fixed left-0 right-0 top-[calc(env(safe-area-inset-top)+0.5rem)] z-40 flex justify-center"
>
<div
class="rounded-full bg-white/90 px-3 py-1 text-xs font-medium text-blue-600 shadow transition-opacity dark:bg-gray-900/90 dark:text-blue-300"
style="opacity: {refreshing ? 1 : Math.min(1, pullProgress)}"
>
{#if refreshing}
<span class="inline-flex items-center gap-2">
<span
class="inline-block h-3 w-3 animate-spin rounded-full border-2 border-blue-200 border-t-blue-600 dark:border-blue-700 dark:border-t-blue-300"
></span>
Aktualisiere…
</span>
{:else}
<svg
class="inline-block h-4 w-4 transition-transform"
style="transform: rotate({Math.min(180, pullProgress * 180)}deg)"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3"
/>
</svg>
{/if}
</div>
</div>
{/if}
{#if feedStale}
<div
class="pointer-events-none fixed left-0 right-0 top-[calc(env(safe-area-inset-top)+0.5rem)] z-40 flex justify-center"
>
<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={() => void refreshStale()}
>
Neue Beiträge tippen zum Aktualisieren
</button>
</div>
{/if}
<!-- Sticky header — opaque fallback for browsers without backdrop-filter. -->
<div
class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 pt-[env(safe-area-inset-top)] backdrop-blur supports-[not(backdrop-filter:blur(0))]:bg-white dark:border-gray-800 dark:bg-gray-900/95 dark:supports-[not(backdrop-filter:blur(0))]:bg-gray-900"
>
<div class="mx-auto flex max-w-2xl items-center justify-between px-4 py-3">
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Galerie</h1>
<div class="flex items-center gap-2">
<!-- Diashow entry — tablet/desktop only (mobile uses the Account page tile). -->
<button
onclick={() => goto('/diashow')}
class="hidden rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100 sm:inline-flex"
aria-label="Diashow starten"
title="Diashow"
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 7.5A2.25 2.25 0 0 1 6 5.25h12A2.25 2.25 0 0 1 20.25 7.5v9A2.25 2.25 0 0 1 18 18.75H6A2.25 2.25 0 0 1 3.75 16.5v-9Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10 9.75 14.5 12 10 14.25v-4.5Z"
/>
</svg>
</button>
<!-- List / Grid toggle -->
<div class="flex items-center gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
<button
onclick={() => switchView('list')}
class="rounded-md p-1.5 transition-colors {viewMode === 'list'
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
aria-label="Listenansicht"
>
<!-- bars-3 -->
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
<button
onclick={() => switchView('grid')}
class="rounded-md p-1.5 transition-colors {viewMode === 'grid'
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
aria-label="Rasteransicht"
>
<!-- squares-2x2 -->
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- List view: hashtag chips -->
{#if viewMode === 'list'}
<div class="mx-auto max-w-2xl px-4 pb-2">
<HashtagChips {hashtags} selected={selectedHashtag} onselect={selectHashtag} />
</div>
{/if}
<!-- Grid view: search bar + autocomplete -->
{#if viewMode === 'grid'}
<div class="mx-auto max-w-2xl px-4 pb-3">
<div class="relative">
<div
class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 focus-within:border-blue-400 focus-within:bg-white focus-within:ring-1 focus-within:ring-blue-200 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-blue-500 dark:focus-within:bg-gray-800"
>
<svg
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
<input
type="search"
placeholder="Nutzer oder #Tag suchen…"
bind:value={searchQuery}
onfocus={(e) => {
openAutocomplete();
// Push the input above the virtual keyboard so suggestions stay visible.
(e.currentTarget as HTMLInputElement).scrollIntoView({
block: 'center',
behavior: 'smooth'
});
}}
onclick={openAutocomplete}
oninput={openAutocomplete}
onblur={() => setTimeout(() => (showAutocomplete = false), 150)}
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none dark:text-gray-100 dark:placeholder-gray-500"
/>
{#if searchQuery}
<button
onclick={() => {
searchQuery = '';
}}
class="shrink-0 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
aria-label="Suche löschen"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
<!-- Autocomplete dropdown -->
{#if showAutocomplete && suggestions.length > 0}
<!-- `preventDefault` on the container's mousedown stops the input from blurring, which
is what would otherwise close this dropdown (see the input's `onblur`) before a
click could land on a suggestion.
That matters because the suggestions used to commit on `onmousedown` — purely to
beat that blur. Two things were wrong with it. It fired on PRESS, so sliding off a
suggestion you didn't mean to hit still applied the filter, with no way to abort.
And `selectSuggestion` sets `showAutocomplete = false`, so the button DESTROYED
ITSELF on the first event of the click sequence: whether the node survived to
`mouseup` came down to whether Svelte's flush landed in between. That is the whole
reason this spec was flaky under load.
Suppressing the blur lets the handler move to `onclick` — the LAST event — so the
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 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"
>
{#each suggestions as item (item.type + ':' + item.value)}
<button
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
onclick={() => selectSuggestion(item)}
>
{#if item.type === 'user'}
<svg
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
/>
</svg>
<span class="font-medium text-gray-900 dark:text-gray-100">{item.value}</span>
{:else}
<span class="font-medium text-blue-500 dark:text-blue-400">#</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{item.value}</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Active filter chips -->
{#if activeFilters.length > 0}
<div class="mt-2 flex flex-wrap items-center gap-1.5">
{#each activeFilters as filter (filter.type + ':' + filter.value)}
<span
class="flex items-center gap-1 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
>
{filter.type === 'tag' ? '#' : ''}{filter.value}
<button
onclick={() => removeFilter(filter)}
class="ml-0.5 hover:text-blue-900 dark:hover:text-blue-100"
aria-label="Filter entfernen"
>
<svg
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</span>
{/each}
{#if activeFilters.length >= 2}
<button
onclick={clearFilters}
class="text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
Alle löschen
</button>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Download banner — appears for everyone (esp. guests) once the host has
released the gallery, so the keepsake download is discoverable from the feed
and not only via the Export tab. -->
{#if $exportStatus.released}
<div class="mx-auto max-w-2xl px-4 pt-3">
<a
href="/export"
data-testid="feed-export-banner"
class="flex items-center gap-3 rounded-2xl border border-primary-300 bg-primary-50 p-4 transition hover:bg-primary-100 dark:border-primary-800/60 dark:bg-primary-950/30 dark:hover:bg-primary-900/30"
>
<span
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300"
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
</span>
<span class="min-w-0 flex-1">
<span class="block font-semibold text-primary-900 dark:text-primary-100"
>Galerie herunterladen</span
>
<span class="block text-sm text-primary-800/80 dark:text-primary-300/80"
>Alle Fotos als ZIP und als Offline-Album sichern.</span
>
</span>
<svg
class="h-5 w-5 shrink-0 text-primary-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
</div>
{/if}
<!-- Content -->
{#if initialLoading && uploads.length === 0}
<div class="mx-auto max-w-2xl" data-testid="feed-skeleton">
{#if viewMode === 'list'}
{#each Array(3) as _, i (i)}
<Skeleton variant="card" />
{/each}
{:else}
<div class="grid grid-cols-3 gap-0.5">
{#each Array(9) as _, i (i)}
<Skeleton variant="tile" />
{/each}
</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 Kamera-Button unten!
</p>
</div>
{:else if viewMode === 'list'}
<!-- List view: chronological full-width cards (DOM-windowed) -->
<div class="mx-auto max-w-2xl">
<VirtualFeed
mode="list"
{uploads}
{myUserId}
onlike={handleLike}
oncomment={openComments}
onselect={(u) => (selectedUpload = u)}
oncontextmenu={openContextSheet}
/>
</div>
{:else}
<!-- 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">
<VirtualFeed
mode="grid"
uploads={displayUploads}
{myUserId}
onlike={handleLike}
oncomment={openComments}
onselect={(u) => (selectedUpload = u)}
oncontextmenu={openContextSheet}
/>
</div>
{/if}
<!-- Infinite scroll sentinel -->
<div class="mx-auto max-w-2xl">
<div bind:this={sentinel} class="h-4"></div>
{#if loadingMore}
<div class="py-4 text-center">
<div
class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600 dark:border-gray-700 dark:border-t-blue-400"
></div>
</div>
{/if}
</div>
</div>
<!-- Lightbox -->
{#if selectedUpload}
<LightboxModal
upload={selectedUpload}
onclose={() => (selectedUpload = null)}
onlike={handleLike}
hasPrev={lightboxIndex > 0}
hasNext={lightboxIndex >= 0 && lightboxIndex < uploads.length - 1}
onprev={() => stepLightbox(-1)}
onnext={() => stepLightbox(1)}
/>
{/if}
<!-- Context sheet for post long-press / kebab tap -->
<ContextSheet
open={contextTarget !== null}
actions={contextActions}
onClose={() => (contextTarget = null)}
/>
<!-- Branded delete confirmation — replaces window.confirm() -->
<ConfirmSheet
open={pendingDelete !== null}
title={pendingDelete?.asHost ? 'Beitrag entfernen?' : 'Beitrag löschen?'}
message={pendingDelete?.asHost
? 'Der Beitrag 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={pendingDelete?.asHost ? 'Entfernen' : 'Löschen'}
tone="danger"
onConfirm={confirmDelete}
onCancel={() => (pendingDelete = null)}
/>
<!-- First-visit onboarding guide -->
<OnboardingGuide />