v_feed computed like_count/comment_count with LEFT JOINs and a GROUP BY. Postgres CAN push `event_id = $1` and the keyset predicate through the view — verified with EXPLAIN, it uses idx_upload_event_created_id — but it CANNOT push ORDER BY ... LIMIT across a GroupAggregate. So every request aggregated every upload in the event, times its likes and comments, and only then sorted and took 21 rows. Cost grew with the event, not with the page. Measured on a throwaway database seeded to a real reception (1000 uploads, 100 guests, 27k likes, 10k comments), same query, same data: before GroupAggregate (actual rows=1001) -> Sort -> Limit 449 ms after Index Scan (actual rows=21) -> Limit -> SubPlans 0.58 ms migration 022 replaces the joins with correlated scalar subqueries, which puts the counts ABOVE the Limit so they run 21 times instead of 1001. Exactly equivalent, not merely close: "like" is keyed (upload_id, user_id) so COUNT(DISTINCT user_id) == count(*), comment.id is the PK so COUNT(DISTINCT c.id) == count(*), and the GROUP BY was on u.id so it was already one row per upload. Column names, order and types are unchanged, so no Rust changes. No new index needed — idx_like_upload and idx_comment_upload already match the subqueries. Note the existing load harness cannot see any of this: e2e/loadtest/driver.mjs creates no likes and no comments, so the expensive path had never been exercised. The amplifier, feed/+page.svelte: every open feed subscribed to `upload-processed` and refetched page 1 — the most expensive page — so ~100 open feeds each fired one per completed upload. Now gated on whether this client actually shows the card that changed, and the debounce is jittered, because a fixed delay just moves a simultaneous herd 800 ms later. Nothing is lost by skipping: a client without the card also missed its `new-upload`, and the reconnect `feed-delta` already schedules a refresh. Load shedding, because the above reduces the risk rather than removing it: db.rs set no acquire_timeout, so sqlx's 30 s default applied — longer than the frontend's own 20 s fetch timeout, meaning the browser gave up while the server kept holding the slot and the work was done for nobody. Now 5 s, and PoolTimedOut maps to 503 + Retry-After instead of a generic 500. That mattered because the upload queue classifies 5xx as transient and retries: a 500 sent the retries straight back into the saturated pool with nothing to pace them. PoolClosed stays Internal — it only occurs during shutdown, where a 503 would invite a retry against a server that is going away. The Retry-After extraction in into_response matches on variants, so unlike message() a missing arm is not a compile error — it would silently drop the header. Pinned by a test covering both retry-carrying variants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1039 lines
39 KiB
Svelte
1039 lines
39 KiB
Svelte
<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 } 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 { filterUploads } from '$lib/feed-filter';
|
||
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);
|
||
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;
|
||
// `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';
|
||
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: () => {
|
||
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 derived from loaded uploads (no extra API calls) ────────
|
||
let allTags = $derived.by(() => {
|
||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local throwaway counter inside a $derived.by; never stored in $state, so no reactivity is involved.
|
||
const freq = new Map<string, number>();
|
||
for (const u of uploads) {
|
||
for (const m of (u.caption ?? '').matchAll(/#(\w+)/g)) {
|
||
const t = m[1].toLowerCase();
|
||
freq.set(t, (freq.get(t) ?? 0) + 1);
|
||
}
|
||
}
|
||
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());
|
||
|
||
// 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 }))
|
||
];
|
||
}
|
||
if (q.startsWith('#')) {
|
||
const prefix = q.slice(1).toLowerCase();
|
||
return frozenTags
|
||
.filter((t) => t.startsWith(prefix))
|
||
.slice(0, 8)
|
||
.map((t) => ({ type: 'tag' as const, value: t }));
|
||
}
|
||
const lower = q.toLowerCase();
|
||
return [
|
||
...frozenUploaders
|
||
.filter((u) => u.toLowerCase().includes(lower))
|
||
.slice(0, 4)
|
||
.map((u) => ({ type: 'user' as const, value: u })),
|
||
...frozenTags
|
||
.filter((t) => t.includes(lower))
|
||
.slice(0, 4)
|
||
.map((t) => ({ type: 'tag' as const, value: t }))
|
||
];
|
||
});
|
||
|
||
// ── Filtered uploads for grid view ───────────────────────────────────────
|
||
let displayUploads = $derived.by(() => {
|
||
if (viewMode === 'list' || activeFilters.length === 0) return uploads;
|
||
return filterUploads(uploads, activeFilters);
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// 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()]);
|
||
connectSse();
|
||
|
||
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', (data) => {
|
||
// Only refetch if this client actually SHOWS the card that changed. Without the
|
||
// membership test every open feed in the venue (~100 at a reception) fired a
|
||
// page-1 refetch for every completed upload — a self-inflicted thundering herd
|
||
// on the most expensive page, triggered by the most common event.
|
||
//
|
||
// Nothing is lost by skipping: a client that doesn't have the card also missed
|
||
// its `new-upload`, and the reconnect `feed-delta` below already calls
|
||
// scheduleInPlaceRefresh().
|
||
try {
|
||
const { upload_id } = JSON.parse(data) as { upload_id: string };
|
||
if (!uploads.some((u) => u.id === upload_id)) return;
|
||
} catch {
|
||
return;
|
||
}
|
||
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.
|
||
/** Debounce floor. */
|
||
const IN_PLACE_REFRESH_MIN_MS = 800;
|
||
/** Jitter added on top. Every open feed receives the same SSE at the same instant, so a
|
||
* fixed delay just moves the herd 800 ms later instead of spreading it. */
|
||
const IN_PLACE_REFRESH_JITTER_MS = 1200;
|
||
|
||
function scheduleInPlaceRefresh() {
|
||
if (inPlaceRefreshTimer) return;
|
||
inPlaceRefreshTimer = setTimeout(
|
||
() => {
|
||
inPlaceRefreshTimer = null;
|
||
void refreshFeedInPlace();
|
||
},
|
||
IN_PLACE_REFRESH_MIN_MS + Math.random() * IN_PLACE_REFRESH_JITTER_MS
|
||
);
|
||
}
|
||
|
||
async function refreshFeedInPlace() {
|
||
try {
|
||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
||
const params = new URLSearchParams();
|
||
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
||
params.set('limit', '20');
|
||
const 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 {
|
||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
||
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 {
|
||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
||
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 border border-primary-400 bg-primary-100 px-4 py-1.5 text-xs font-semibold text-primary-800 shadow-lg hover:border-primary-500 hover:bg-primary-200 dark:border-primary-500/50 dark:bg-primary-950/60 dark:text-primary-200"
|
||
onclick={() => {
|
||
feedStale = false;
|
||
void loadFeed(true);
|
||
}}
|
||
>
|
||
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 overflow-hidden 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 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}
|
||
{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="btn btn-ghost btn-sm mt-2"
|
||
>Filter zurücksetzen</button
|
||
>
|
||
</div>
|
||
{:else}
|
||
<VirtualFeed
|
||
mode="grid"
|
||
uploads={displayUploads}
|
||
{myUserId}
|
||
onlike={handleLike}
|
||
oncomment={openComments}
|
||
onselect={(u) => (selectedUpload = u)}
|
||
oncontextmenu={openContextSheet}
|
||
/>
|
||
{/if}
|
||
</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={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 />
|