Files
EventSnap/frontend/src/routes/feed/+page.svelte
MechaCat02 8faf702208 feat(media): authenticated signed media gateway (C1, C2-sink, C3, H2, H10)
Replaces the DB-blind `/media` ServeDir with a signed, DB-aware gateway at
`GET /media/{kind}/{id}`. Every media byte now flows through an HMAC-SHA256
signature check (minted into feed/upload DTOs for authenticated members; <img>
can't carry a Bearer header) plus a DB lookup:

- C1: export ZIP/HTML have no upload row, so they are unreachable by path —
  download stays behind the authenticated /export endpoints.
- C2 (sink): responses carry X-Content-Type-Options: nosniff and a locked-down
  CSP (default-src 'none'; sandbox), neutralizing any active content.
- C3 / H2: find_by_id filters deleted_at and the handler rejects ban-hidden
  uploaders, so deleted and moderated artifacts 404 — and the unauthenticated
  get_original alias (the H2 hole) is removed entirely.
- H10: delete paths (owner + host) now unlink original/preview/thumbnail after
  commit; soft_delete returns the paths; an hourly reaper reclaims disk for
  rows soft-deleted past a 1-day grace and hard-deletes them (FKs cascade).

Signed URLs are bucketed to a 1h window so they stay stable across feed polls
(browser cache hits) while expiring within 24h. media_token sign/verify has a
unit test (roundtrip + tamper + expiry).

Frontend: FeedUpload/pickMediaUrl now use the backend-provided signed
original_url; no client constructs a media path anymore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:30:30 +02:00

615 lines
23 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 FeedGrid from '$lib/components/FeedGrid.svelte';
import FeedListCard from '$lib/components/FeedListCard.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 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);
let sentinel: HTMLDivElement;
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: () => {
if (target.original_url) window.open(target.original_url, '_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;
const tags = activeFilters.filter((f) => f.type === 'tag').map((f) => f.value);
const users = activeFilters.filter((f) => f.type === 'user').map((f) => f.value);
return uploads.filter((u) => {
const cap = (u.caption ?? '').toLowerCase();
const passTag = !tags.length || tags.some((t) => cap.includes('#' + t));
const passUser = !users.length || users.includes(u.uploader_name);
return passTag && passUser;
});
});
// ─────────────────────────────────────────────────────────────────────────
// 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 */ }
}),
onSseEvent('upload-processed', () => loadFeed(true)),
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 */ }
}),
onSseEvent('like-update', () => loadFeed(true)),
onSseEvent('new-comment', () => loadFeed(true)),
// 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;
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];
}
if (delta.deleted_ids.length) {
const dead = new Set(delta.deleted_ids);
uploads = uploads.filter((u) => !dead.has(u.id));
}
} catch { /* ignore */ }
})
);
if (sentinel) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && nextCursor && !loadingMore) loadMore();
},
{ rootMargin: '200px' }
);
observer.observe(sentinel);
}
});
onDestroy(() => {
disconnectSse();
for (const unsub of unsubscribers) unsub();
});
async function loadFeed(refresh = 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 {
await api.post(`/upload/${id}/like`);
vibrate(10);
uploads = uploads.map((u) =>
u.id === id
? { ...u, liked_by_me: !u.liked_by_me, like_count: u.liked_by_me ? u.like_count - 1 : u.like_count + 1 }
: u
);
if (selectedUpload?.id === id) {
selectedUpload = {
...selectedUpload,
liked_by_me: !selectedUpload.liked_by_me,
like_count: selectedUpload.liked_by_me ? selectedUpload.like_count - 1 : selectedUpload.like_count + 1,
};
}
} 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-2 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}
<!-- Sticky header — opaque fallback for browsers without backdrop-filter. -->
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 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 -->
<div class="mx-auto max-w-2xl">
{#each uploads as upload (upload.id)}
<FeedListCard
{upload}
isOwn={upload.user_id === myUserId}
onlike={handleLike}
oncomment={openComments}
onselect={(u) => (selectedUpload = u)}
oncontextmenu={openContextSheet}
/>
{/each}
</div>
{:else}
<!-- Grid view: 3-col, filters applied -->
<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>
<button onclick={clearFilters} class="mt-2 text-sm text-blue-600 hover:underline dark:text-blue-400">Filter zurücksetzen</button>
</div>
{:else}
<FeedGrid
uploads={displayUploads}
onlike={handleLike}
oncomment={openComments}
onselect={(u) => (selectedUpload = u)}
oncontextmenu={openContextSheet}
threeCol={true}
/>
{/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 />