<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); // 0–1+ 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 1–3 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 />
