Files
EventSnap/frontend/src/routes/feed/+page.svelte
fabi 36fe59caa5 fix(user-flow): persona-audit fixes — ban replay, locked-upload data loss, host UX, admin session
Follows the perf + security + user-flow work with a role/persona audit (guest, host,
admin, projector) and fixes across three review rounds. Highlights:

HIGH
- Ban now replays on reconnect. A ban isn't a soft-delete, and the `user-hidden` SSE has
  no replay, so a client that missed it (esp. the unattended diashow) kept cycling a
  banned user's slides. New `uploads_hidden_at` (migration 013) + `hidden_user_ids` in
  /feed/delta; feed + diashow evict those users. Applied even on a truncated delta.

MEDIUM
- Locked-upload data loss: a photo staged offline during a lock/release was purged as a
  terminal 4xx and lost when the host reopened. New reversible `uploads_locked` error code;
  the queue keeps the blob and auto-resumes on the `event-opened` SSE.
- Reopen after release now warns (ConfirmSheet) that it revokes the published keepsake.
- Host "forgotten-PIN" badge updates live (`pin-reset-requested` was broadcast but never
  in KNOWN_EVENTS / subscribed); host page also refetches on `pin-reset` so a two-host
  race can't hand out a conflicting PIN.
- Ban modal copy fixed (read-only ban, not "session ended"); Degradieren/Sperren/Entsperren
  hidden on peer-host rows for non-admins (they always 403'd).
- Host dashboard shows live keepsake generation progress / ready state + link to /export.
- Admin JWT moved to sessionStorage (§11.1) to bound exposure on shared devices.

Export generation guard (H1 from the prior round, hardened): per-(event,type) `release_seq`
(migration 012) with seq-guarded claim/finalize/mark_failed/update_progress, per-generation
temp/final paths, download follows `file_path`, prune only strictly-older generations.

LOW: diashow coalesces upload-processed (avoids self-rate-limit); event-closed reconciles
galleryReleased; /recover gains a forgot-PIN request + drops a stale cached PIN on 401;
delta `>=` tie-break + 429 retry; misc copy/labels.

Adds e2e: ban-replay, upload-lock-code, and rewrites export-reopen-rerelease with a
data-completeness test. Reconciles USER_JOURNEYS §9/§11.

Verified: cargo build clean, 40 unit tests, svelte-check 0 errors, 33 frontend unit
tests, 155 e2e passing on chromium-desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:06:28 +02:00

736 lines
29 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 { api } from '$lib/api';
import { connectSse, disconnectSse, onSseEvent } 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 { toast, toastError } from '$lib/toast-store';
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
import { vibrate } from '$lib/haptics';
import { filterUploads } from '$lib/feed-filter';
import 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);
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;
let pendingDeleteId = $state<string | 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';
return () => {
document.documentElement.style.overscrollBehaviorY = prev;
};
});
// 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: () => { pendingDeleteId = target.id; }
});
}
return actions;
}
function openContextSheet(upload: FeedUpload) {
contextTarget = upload;
}
async function confirmDelete() {
const id = pendingDeleteId;
if (!id) return;
pendingDeleteId = null;
try {
await api.delete(`/upload/${id}`);
uploads = uploads.filter((u) => u.id !== id);
if (selectedUpload?.id === id) selectedUpload = null;
void refreshQuota();
} catch (e) {
toastError(e);
}
}
// ── Autocomplete derived from loaded uploads (no extra API calls) ────────
let allTags = $derived.by(() => {
const freq = new Map<string, number>();
for (const u of uploads) {
for (const m of (u.caption ?? '').matchAll(/#(\w+)/g)) {
const t = m[1].toLowerCase();
freq.set(t, (freq.get(t) ?? 0) + 1);
}
}
return [...freq.entries()].sort((a, b) => b[1] - a[1]).map(([t]) => t);
});
let allUploaders = $derived([...new Set(uploads.map((u) => u.uploader_name))].sort());
let suggestions = $derived.by((): Filter[] => {
const q = searchQuery.trim();
if (!q) {
// Show top suggestions on focus
if (!showAutocomplete) return [];
return [
...allUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })),
...allTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t })),
];
}
if (q.startsWith('#')) {
const prefix = q.slice(1).toLowerCase();
return allTags
.filter((t) => t.startsWith(prefix))
.slice(0, 8)
.map((t) => ({ type: 'tag' as const, value: t }));
}
const lower = q.toLowerCase();
return [
...allUploaders
.filter((u) => u.toLowerCase().includes(lower))
.slice(0, 4)
.map((u) => ({ type: 'user' as const, value: u })),
...allTags
.filter((t) => t.includes(lower))
.slice(0, 4)
.map((t) => ({ type: 'tag' as const, value: t })),
];
});
// ── Filtered uploads for grid view ───────────────────────────────────────
let displayUploads = $derived.by(() => {
if (viewMode === 'list' || activeFilters.length === 0) return uploads;
return filterUploads(uploads, activeFilters);
});
// ─────────────────────────────────────────────────────────────────────────
// 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');
}
}
await Promise.all([loadFeed(), loadHashtags()]);
connectSse();
unsubscribers.push(
onSseEvent('new-upload', (data) => {
try {
const upload: FeedUpload = JSON.parse(data);
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();
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 */ }
}
// Debounced page-1 fetch that *merges* (updates existing cards in place, prepends
// genuinely new ones) rather than replacing the array — preserves scroll and any
// pages already loaded below the fold.
function scheduleInPlaceRefresh() {
if (inPlaceRefreshTimer) return;
inPlaceRefreshTimer = setTimeout(() => {
inPlaceRefreshTimer = null;
void refreshFeedInPlace();
}, 800);
}
async function refreshFeedInPlace() {
try {
const params = new URLSearchParams();
if (selectedHashtag) params.set('hashtag', selectedHashtag);
params.set('limit', '20');
const res = await api.get<FeedResponse>(`/feed?${params}`);
const byId = new Map(res.uploads.map((u) => [u.id, u]));
const known = new Set(uploads.map((u) => u.id));
uploads = uploads.map((u) => byId.get(u.id) ?? u);
const fresh = res.uploads.filter((u) => !known.has(u.id));
if (fresh.length) uploads = [...fresh, ...uploads];
} catch {
// Background refresh — stay quiet, the next event or pull-to-refresh retries.
}
}
async function loadFeed(refresh = false) {
// Any full refresh (pill tap, pull-to-refresh, filter change) resyncs page 1,
// so the "new posts" pill is no longer relevant — clear it here rather than
// only in the pill's own onclick, or a pull-to-refresh leaves it stranded.
if (refresh) feedStale = false;
try {
const params = new URLSearchParams();
if (!refresh && nextCursor) params.set('cursor', nextCursor);
if (selectedHashtag) params.set('hashtag', selectedHashtag);
params.set('limit', '20');
const res = await api.get<FeedResponse>(`/feed?${params}`);
uploads = res.uploads;
nextCursor = res.next_cursor;
} catch (e) {
// Initial / user-triggered refresh is worth surfacing — background SSE refetches are noisier and silenced below.
if (!refresh) toastError(e);
} finally {
initialLoading = false;
}
}
async function loadMore() {
if (!nextCursor || loadingMore) return;
loadingMore = true;
try {
const params = new URLSearchParams();
params.set('cursor', nextCursor);
if (selectedHashtag) params.set('hashtag', selectedHashtag);
params.set('limit', '20');
const res = await api.get<FeedResponse>(`/feed?${params}`);
uploads = [...uploads, ...res.uploads];
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 pullRefresh() {
if (refreshing) return;
refreshing = true;
pullProgress = 0;
vibrate(10);
try {
nextCursor = null;
await Promise.all([loadFeed(true), loadHashtags()]);
} finally {
refreshing = false;
}
}
function selectHashtag(tag: string | null) {
selectedHashtag = tag;
nextCursor = null;
loadFeed();
}
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 (!activeFilters.some((f) => f.type === item.type && f.value === item.value)) {
activeFilters = [...activeFilters, item];
}
searchQuery = '';
showAutocomplete = false;
}
function removeFilter(item: Filter) {
activeFilters = activeFilters.filter((f) => !(f.type === item.type && f.value === item.value));
}
function clearFilters() {
activeFilters = [];
searchQuery = '';
}
function switchView(mode: 'list' | 'grid') {
viewMode = mode;
if (mode === 'list') {
searchQuery = '';
showAutocomplete = false;
}
}
</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 bg-blue-600 px-4 py-1.5 text-xs font-semibold text-white shadow-lg hover:bg-blue-700"
onclick={() => { feedStale = false; void loadFeed(true); }}
>
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) => {
showAutocomplete = true;
// Push the input above the virtual keyboard so suggestions stay visible.
(e.currentTarget as HTMLInputElement).scrollIntoView({ block: 'center', behavior: 'smooth' });
}}
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}
<div class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900">
{#each suggestions as item}
<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"
onmousedown={() => 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}
<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>
<!-- Content -->
{#if initialLoading && uploads.length === 0}
<div class="mx-auto max-w-2xl" data-testid="feed-skeleton">
{#if viewMode === 'list'}
{#each Array(3) as _}
<Skeleton variant="card" />
{/each}
{:else}
<div class="grid grid-cols-3 gap-0.5">
{#each Array(9) as _}
<Skeleton variant="tile" />
{/each}
</div>
{/if}
</div>
{:else if uploads.length === 0}
<div class="py-20 text-center">
<p class="text-lg text-gray-400 dark:text-gray-500">Noch keine Fotos.</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Tippe auf den Plus-Button unten!</p>
</div>
{:else if viewMode === 'list'}
<!-- List view: chronological full-width cards (DOM-windowed) -->
<div class="mx-auto max-w-2xl">
<VirtualFeed
mode="list"
uploads={uploads}
{myUserId}
onlike={handleLike}
oncomment={openComments}
onselect={(u) => (selectedUpload = u)}
oncontextmenu={openContextSheet}
/>
</div>
{:else}
<!-- Grid view: 3-col, filters applied (DOM-windowed by row) -->
<div class="mx-auto max-w-2xl">
{#if displayUploads.length === 0}
<div class="py-16 text-center">
<p class="text-sm text-gray-400 dark:text-gray-500">Keine Treffer für die gewählten Filter.</p>
{#if nextCursor}
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">Es sind noch nicht alle Beiträge geladen — scrolle weiter, um mehr zu durchsuchen.</p>
{/if}
<button onclick={clearFilters} class="mt-2 text-sm text-blue-600 hover:underline dark:text-blue-400">Filter zurücksetzen</button>
</div>
{:else}
<VirtualFeed
mode="grid"
uploads={displayUploads}
{myUserId}
onlike={handleLike}
oncomment={openComments}
onselect={(u) => (selectedUpload = u)}
oncontextmenu={openContextSheet}
/>
{/if}
</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}
/>
{/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={pendingDeleteId !== null}
title="Beitrag löschen?"
message="Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Löschen"
tone="danger"
onConfirm={confirmDelete}
onCancel={() => (pendingDeleteId = null)}
/>
<!-- First-visit onboarding guide -->
<OnboardingGuide />