fix(feed): survive a bad network, and let the lightbox actually browse
Four things a guest on congested venue wifi would have hit, and one they would have
hit immediately.
A FAILED FEED LOAD CLAIMED THE GALLERY WAS EMPTY. `loadFeed` caught, toasted for five
seconds and left `uploads` empty, so the page fell through to "Noch keine Fotos. Tippe
auf den Kamera-Button unten!" — the most likely first impression at the party, and a
lie. There is now a distinct error state with "Erneut laden". Refreshes suppressed the
toast entirely, so pull-to-refresh and the "Neue Beiträge" pill failed in total
silence; they now report, and the pill survives its own failure instead of clearing
before the request.
THE FILTER-EMPTY STATE WAS DEAD CODE. With filtering server-side `displayUploads` is a
plain alias of `uploads`, so the grid's "Keine Treffer für die gewählten Filter." plus
its reset button sat behind an identical earlier branch and could never render — a
guest tapping a chip with no matches was told to go take a photo.
SSE COULD FREEZE THE FEED FOR THE WHOLE EVENING. Nothing in the feed ever refetched on
a timer; every update path was triggered exclusively by a stream event. Behind a proxy
that buffers `text/event-stream` `onopen` never fires, so the guest saw only the photos
that were on screen when they arrived; and a socket left half-open by an AP roam is
worse, because `connectSse` early-returns on a non-null EventSource and nothing ever
reconnects. A pure silence timer is not implementable — the backend sends keep-alives
as SSE comments, which the EventSource parser discards without dispatching — so
liveness is established on evidence instead: a jittered 60-120s `/feed/delta` backstop
that reconnects when a poll returns content the stream never delivered. The ticket
round-trip also seeds the delta cursor before the EventSource is created, so the
backstop has a `since` even if `onopen` never fires.
THE PILL COLLAPSED A DEEPLY-SCROLLED FEED to 20 items and dumped the guest at an
arbitrary scroll position — the exact yank the pill exists to avoid. It merges now.
The refresh debounce was 800ms + jitter, which during a burst is roughly one feed query
per client every two seconds; at 100 guests that approaches the 60/min per-user limit,
and the resulting 429s were swallowed by a bare `catch {}`, so the feed would simply
stop updating with no signal. Now 8s + jitter, coalescing, and skipped entirely while
the page is hidden.
Not one `<img>` in the app had an `onerror`. `pickMediaUrl` falls back to the original
whenever preview and thumbnail are null — i.e. for everything still compressing, which
during a burst is the top of the feed — so a 404 there rendered an empty grey box with
`alt=""`, not even a message. Each now retries once, then shows the placeholder.
The lightbox had no swipe, no prev/next and no arrow keys, so browsing 300 photos meant
closing and reopening the modal for every one — while FEATURES.md and USER_JOURNEYS
both claimed swipe shipped. It now has chevrons (44px, German aria-labels, hidden at
the ends), arrow keys, and horizontal swipe, with focus handed to the surviving control
so a disappearing chevron can't drop focus to `<body>`. Comment deletion was a ~14px
`✕` four pixels from the text that deleted permanently on one tap, while deleting a
POST two components away goes through a ConfirmSheet; it now matches.
`feed-filter.ts` and its test are deleted — with the server filtering, they were dead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,25 @@ export class ApiError extends Error {
|
|||||||
|
|
||||||
const TIMEOUT_MS = 20_000;
|
const TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
/** Pages that ARE the recovery flow — redirecting from them would loop. */
|
||||||
|
const AUTH_ROUTES = ['/join', '/recover'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a guest whose session died back to the join screen.
|
||||||
|
*
|
||||||
|
* Deliberately uses `window.location` rather than SvelteKit's `goto`: `toast-store` already
|
||||||
|
* imports `ApiError` from this module, so pulling a store or `$app/navigation` in here would
|
||||||
|
* create an import cycle. A full document load is also the more correct behaviour after a
|
||||||
|
* session loss — it resets every module-level store, which is exactly what we want, and the
|
||||||
|
* queued upload blobs live in IndexedDB so they survive it.
|
||||||
|
*/
|
||||||
|
function redirectToJoin(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const path = window.location.pathname;
|
||||||
|
if (AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`))) return;
|
||||||
|
window.location.assign('/join');
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
@@ -69,6 +88,17 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
|||||||
// simply get a 403 "gesperrt" toast on writes.
|
// simply get a 403 "gesperrt" toast on writes.
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
|
// Clearing auth alone leaves the guest stranded: the bottom nav and FAB are
|
||||||
|
// gated on `isAuthenticated` so they simply vanish, route guards only run in
|
||||||
|
// onMount (which does not re-run), and a standalone PWA has no URL bar — so
|
||||||
|
// there is no way back to /join. Real triggers mid-event are a host PIN reset
|
||||||
|
// (which revokes that guest's sessions) and a redeployed JWT_SECRET.
|
||||||
|
// Queued upload blobs survive in IndexedDB and are picked up again after
|
||||||
|
// re-joining — via the `onSetAuth` hook in upload-queue.ts, NOT the boot-time
|
||||||
|
// call in +layout.svelte: this redirect lands on /join with no token, so the
|
||||||
|
// layout's `if (getToken())` skips it, and the subsequent recover navigates
|
||||||
|
// with `goto()`, which never re-runs `onMount`.
|
||||||
|
redirectToJoin();
|
||||||
}
|
}
|
||||||
const d = (data ?? {}) as { error?: string; message?: string };
|
const d = (data ?? {}) as { error?: string; message?: string };
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
|
|||||||
@@ -27,6 +27,53 @@
|
|||||||
|
|
||||||
const mediaSrc = $derived(pickMediaUrl($dataMode, upload));
|
const mediaSrc = $derived(pickMediaUrl($dataMode, upload));
|
||||||
|
|
||||||
|
// Video cards show a poster if one exists; `pickMediaUrl` is not used here because it is
|
||||||
|
// mime-agnostic and would hand back the raw MP4 for an <img>.
|
||||||
|
const posterSrc = $derived(upload.thumbnail_url ?? upload.preview_url ?? '');
|
||||||
|
|
||||||
|
// ── Media fallback ───────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A failed <img> rendered as an empty grey box with `alt=""` — no icon, no message,
|
||||||
|
// nothing to tap. And the failure is routine rather than exotic: `pickMediaUrl` falls
|
||||||
|
// back to `/original` whenever preview AND thumbnail are still null, i.e. for everything
|
||||||
|
// still compressing, which in a newest-first feed is the card at the top during every
|
||||||
|
// burst. That also makes the cause usually TRANSIENT, so one delayed retry of the same
|
||||||
|
// URL recovers most of them; the nonce exists because the browser would otherwise replay
|
||||||
|
// its cached failure rather than re-request. Two strikes and we fall through to the
|
||||||
|
// placeholder this card already draws for "no derivative yet".
|
||||||
|
const MEDIA_RETRY_MS = 4000;
|
||||||
|
let mediaRetryNonce = $state(0);
|
||||||
|
let mediaFailed = $state(false);
|
||||||
|
let mediaRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function withNonce(url: string): string {
|
||||||
|
if (!url || !mediaRetryNonce) return url;
|
||||||
|
return `${url}${url.includes('?') ? '&' : '?'}r=${mediaRetryNonce}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displaySrc = $derived(withNonce(isVideo(upload.mime_type) ? posterSrc : mediaSrc));
|
||||||
|
|
||||||
|
// A url change — the SSE `upload-processed` swapping the original for a real preview is
|
||||||
|
// the common one — deserves a clean attempt rather than inheriting the old verdict.
|
||||||
|
$effect(() => {
|
||||||
|
void mediaSrc;
|
||||||
|
void posterSrc;
|
||||||
|
mediaRetryNonce = 0;
|
||||||
|
mediaFailed = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleMediaError() {
|
||||||
|
if (mediaRetryNonce) {
|
||||||
|
mediaFailed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
|
||||||
|
mediaRetryTimer = setTimeout(() => {
|
||||||
|
mediaRetryTimer = null;
|
||||||
|
mediaRetryNonce = Date.now();
|
||||||
|
}, MEDIA_RETRY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
function relativeTime(iso: string, nowMs: number): string {
|
function relativeTime(iso: string, nowMs: number): string {
|
||||||
const diff = nowMs - new Date(iso).getTime();
|
const diff = nowMs - new Date(iso).getTime();
|
||||||
const mins = Math.floor(diff / 60000);
|
const mins = Math.floor(diff / 60000);
|
||||||
@@ -61,6 +108,7 @@
|
|||||||
// card mid-animation) so it can't fire against a stale component.
|
// card mid-animation) so it can't fire against a stale component.
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (burstTimer) clearTimeout(burstTimer);
|
if (burstTimer) clearTimeout(burstTimer);
|
||||||
|
if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -124,13 +172,14 @@
|
|||||||
<HeartBurst active={heartBurst} />
|
<HeartBurst active={heartBurst} />
|
||||||
{#if isVideo(upload.mime_type)}
|
{#if isVideo(upload.mime_type)}
|
||||||
<div class="relative aspect-video w-full bg-gray-900">
|
<div class="relative aspect-video w-full bg-gray-900">
|
||||||
{#if upload.thumbnail_url || upload.preview_url}
|
{#if posterSrc && !mediaFailed}
|
||||||
<img
|
<img
|
||||||
src={upload.thumbnail_url ?? upload.preview_url ?? ''}
|
src={displaySrc}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-full w-full object-cover opacity-80"
|
class="h-full w-full object-cover opacity-80"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
onerror={handleMediaError}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
@@ -143,17 +192,18 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if mediaSrc}
|
{:else if mediaSrc && !mediaFailed}
|
||||||
<!-- Reserve the same 4/5 box the skeleton uses so the card doesn't collapse
|
<!-- Reserve the same 4/5 box the skeleton uses so the card doesn't collapse
|
||||||
to height 0 and reflow as images stream in. The uncropped original is one
|
to height 0 and reflow as images stream in. The uncropped original is one
|
||||||
tap away in the lightbox. -->
|
tap away in the lightbox. -->
|
||||||
<div class="aspect-[4/5] w-full bg-gray-100 dark:bg-gray-800">
|
<div class="aspect-[4/5] w-full bg-gray-100 dark:bg-gray-800">
|
||||||
<img
|
<img
|
||||||
src={mediaSrc}
|
src={displaySrc}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
onerror={handleMediaError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -209,6 +259,7 @@
|
|||||||
{#if $commentsEnabled}
|
{#if $commentsEnabled}
|
||||||
<button
|
<button
|
||||||
onclick={() => oncomment(upload.id)}
|
onclick={() => oncomment(upload.id)}
|
||||||
|
aria-label="Kommentare anzeigen"
|
||||||
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400"
|
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy, tick } from 'svelte';
|
||||||
|
import { beforeNavigate } from '$app/navigation';
|
||||||
import type { FeedUpload } from '$lib/types';
|
import type { FeedUpload } from '$lib/types';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { onSseEvent } from '$lib/sse';
|
import { onSseEvent } from '$lib/sse';
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
import { vibrate } from '$lib/haptics';
|
import { vibrate } from '$lib/haptics';
|
||||||
import { commentsEnabled } from '$lib/event-config-store';
|
import { commentsEnabled } from '$lib/event-config-store';
|
||||||
import HeartBurst from './HeartBurst.svelte';
|
import HeartBurst from './HeartBurst.svelte';
|
||||||
|
import ConfirmSheet from './ConfirmSheet.svelte';
|
||||||
|
|
||||||
const COMMENT_MAX = 500;
|
const COMMENT_MAX = 500;
|
||||||
|
|
||||||
@@ -30,9 +32,41 @@
|
|||||||
upload: FeedUpload;
|
upload: FeedUpload;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
onlike: (id: string) => void;
|
onlike: (id: string) => void;
|
||||||
|
/** Whether a previous/next item exists in the caller's list. Controls the chevrons. */
|
||||||
|
hasPrev?: boolean;
|
||||||
|
hasNext?: boolean;
|
||||||
|
onprev?: () => void;
|
||||||
|
onnext?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { upload, onclose, onlike }: Props = $props();
|
let {
|
||||||
|
upload,
|
||||||
|
onclose,
|
||||||
|
onlike,
|
||||||
|
hasPrev = false,
|
||||||
|
hasNext = false,
|
||||||
|
onprev,
|
||||||
|
onnext
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// The system back gesture must close the photo, not leave the app.
|
||||||
|
//
|
||||||
|
// On a phone, back is how you dismiss a full-screen view — but this modal is component
|
||||||
|
// state, not a route, so back navigated away from /feed entirely and the guest lost their
|
||||||
|
// scroll position. Since the modal is only mounted while open, this subscription is
|
||||||
|
// active exactly while it is open (Svelte tears it down on destroy).
|
||||||
|
//
|
||||||
|
// Cancelling a popstate navigation is explicitly supported: SvelteKit counteracts it with
|
||||||
|
// `history.go`, restoring the entry we just left (see its client runtime, "if a
|
||||||
|
// popstate-driven navigation is cancelled"). So the URL stays put and the photo closes.
|
||||||
|
// Only `popstate` is intercepted — an in-app link or a programmatic `goto` should still
|
||||||
|
// navigate, closing the modal with the page.
|
||||||
|
beforeNavigate((nav) => {
|
||||||
|
if (nav.type === 'popstate') {
|
||||||
|
nav.cancel();
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let comments = $state<CommentDto[]>([]);
|
let comments = $state<CommentDto[]>([]);
|
||||||
let newComment = $state('');
|
let newComment = $state('');
|
||||||
@@ -56,6 +90,115 @@
|
|||||||
: pickMediaUrl($dataMode, upload)
|
: pickMediaUrl($dataMode, upload)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Prev / next ──────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Until now the only way out of a photo was to close the modal, so browsing hundreds
|
||||||
|
// of party photos meant open-close-open-close all evening — while FEATURES.md and
|
||||||
|
// USER_JOURNEYS §8.6/§17 both promised swipe navigation. Three affordances, because
|
||||||
|
// each covers a different user: on-screen chevrons are the discoverable baseline (a
|
||||||
|
// swipe nobody knows about is not a feature), arrow keys for the projector laptop,
|
||||||
|
// swipe for the phone in one hand.
|
||||||
|
let prevBtn = $state<HTMLButtonElement | undefined>();
|
||||||
|
let nextBtn = $state<HTMLButtonElement | undefined>();
|
||||||
|
let closeBtn = $state<HTMLButtonElement | undefined>();
|
||||||
|
|
||||||
|
async function step(dir: -1 | 1) {
|
||||||
|
if (dir === -1) {
|
||||||
|
if (!hasPrev) return;
|
||||||
|
onprev?.();
|
||||||
|
} else {
|
||||||
|
if (!hasNext) return;
|
||||||
|
onnext?.();
|
||||||
|
}
|
||||||
|
// Stepping onto the first/last item removes the chevron that was just used. A
|
||||||
|
// focused node disappearing drops focus to <body> — outside `focusTrap`'s node — so
|
||||||
|
// the next Tab would leave for the browser chrome instead of cycling the modal.
|
||||||
|
// Hand focus to whichever control survived.
|
||||||
|
await tick();
|
||||||
|
if (document.activeElement === document.body) {
|
||||||
|
const fallback = dir === -1 ? nextBtn : prevBtn;
|
||||||
|
(fallback ?? closeBtn)?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||||
|
// Never hijack caret movement inside the comment composer.
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void step(e.key === 'ArrowLeft' ? -1 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe. Bound to the media wrapper rather than the whole modal so it cannot fight the
|
||||||
|
// comment list's vertical scroll, and read on touchend rather than continuously so the
|
||||||
|
// browser keeps ownership of the gesture until we know it was horizontal.
|
||||||
|
const SWIPE_MIN_PX = 50;
|
||||||
|
let touchStartX = 0;
|
||||||
|
let touchStartY = 0;
|
||||||
|
let touchTracking = false;
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
// A second finger means pinch-zoom, which must not turn into a page change.
|
||||||
|
touchTracking = e.touches.length === 1;
|
||||||
|
if (!touchTracking) return;
|
||||||
|
touchStartX = e.touches[0].clientX;
|
||||||
|
touchStartY = e.touches[0].clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd(e: TouchEvent) {
|
||||||
|
if (!touchTracking) return;
|
||||||
|
touchTracking = false;
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
if (!t) return;
|
||||||
|
const dx = t.clientX - touchStartX;
|
||||||
|
const dy = t.clientY - touchStartY;
|
||||||
|
// Require a decisively HORIZONTAL gesture: the modal body scrolls vertically, so a
|
||||||
|
// diagonal drag must not steal a photo change from someone who meant to scroll.
|
||||||
|
if (Math.abs(dx) < SWIPE_MIN_PX || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||||
|
void step(dx < 0 ? 1 : -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Media fallback ───────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A failed <img> used to render as an empty black box with `alt=""` — no icon, no
|
||||||
|
// message, nothing to tap. And the failure is common rather than exotic: `pickMediaUrl`
|
||||||
|
// falls back to `/original` whenever preview AND thumbnail are still null, i.e. for
|
||||||
|
// everything still compressing, which in a newest-first feed is the top of the list
|
||||||
|
// during every burst. That also makes the cause usually TRANSIENT, so one delayed retry
|
||||||
|
// of the same URL recovers most of them; the nonce is there because the browser would
|
||||||
|
// otherwise replay its cached failure rather than re-request.
|
||||||
|
const MEDIA_RETRY_MS = 4000;
|
||||||
|
let mediaRetryNonce = $state(0);
|
||||||
|
let mediaFailed = $state(false);
|
||||||
|
let mediaRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const displaySrc = $derived(
|
||||||
|
mediaRetryNonce
|
||||||
|
? `${mediaSrc}${mediaSrc.includes('?') ? '&' : '?'}r=${mediaRetryNonce}`
|
||||||
|
: mediaSrc
|
||||||
|
);
|
||||||
|
|
||||||
|
// A different photo — or the same one after `upload-processed` swapped the original for
|
||||||
|
// a real preview — deserves a clean attempt rather than inheriting the old verdict.
|
||||||
|
$effect(() => {
|
||||||
|
void mediaSrc;
|
||||||
|
mediaRetryNonce = 0;
|
||||||
|
mediaFailed = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleMediaError() {
|
||||||
|
if (mediaRetryNonce) {
|
||||||
|
mediaFailed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
|
||||||
|
mediaRetryTimer = setTimeout(() => {
|
||||||
|
mediaRetryTimer = null;
|
||||||
|
mediaRetryNonce = Date.now();
|
||||||
|
}, MEDIA_RETRY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
function triggerHeartBurst() {
|
function triggerHeartBurst() {
|
||||||
heartBurst = true;
|
heartBurst = true;
|
||||||
vibrate(10);
|
vibrate(10);
|
||||||
@@ -90,6 +233,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (burstTimer) clearTimeout(burstTimer);
|
if (burstTimer) clearTimeout(burstTimer);
|
||||||
|
if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
|
||||||
unsubCommentDeleted();
|
unsubCommentDeleted();
|
||||||
unsubNewComment();
|
unsubNewComment();
|
||||||
});
|
});
|
||||||
@@ -128,16 +272,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Comment deletion is permanent and had NO confirmation at all — from a ~14px ✕ glyph
|
||||||
* `asHost` routes to the moderation endpoint. The guest route only ever deletes the
|
// sitting a few pixels from the comment text, which a host could also use on someone
|
||||||
* caller's OWN comment, and it refuses a banned author outright — so without this a
|
// else's words. Deleting a POST two components away has always gone through ConfirmSheet;
|
||||||
* host who banned an abusive guest was left with the abuse still on screen and no way
|
// this is the same irreversible act and now takes the same route.
|
||||||
* to remove it, since the ban itself blocks the author's own delete.
|
// `asHost` routes to the moderation endpoint: the guest route only ever deletes the
|
||||||
*/
|
// caller's OWN comment, and it refuses a banned author outright — so without it a host
|
||||||
async function deleteComment(id: string, asHost: boolean) {
|
// who banned an abusive guest was left with the abuse on screen and no way to remove it,
|
||||||
|
// since the ban itself blocks the author's own delete.
|
||||||
|
let pendingCommentDelete = $state<{ id: string; asHost: boolean } | null>(null);
|
||||||
|
|
||||||
|
async function confirmDeleteComment() {
|
||||||
|
const pending = pendingCommentDelete;
|
||||||
|
if (!pending) return;
|
||||||
|
pendingCommentDelete = null;
|
||||||
try {
|
try {
|
||||||
await api.delete(asHost ? `/host/comment/${id}` : `/comment/${id}`);
|
await api.delete(pending.asHost ? `/host/comment/${pending.id}` : `/comment/${pending.id}`);
|
||||||
comments = comments.filter((c) => c.id !== id);
|
comments = comments.filter((c) => c.id !== pending.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e);
|
toastError(e);
|
||||||
}
|
}
|
||||||
@@ -157,6 +308,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -166,12 +319,26 @@
|
|||||||
use:scrollLock
|
use:scrollLock
|
||||||
use:modalInert
|
use:modalInert
|
||||||
>
|
>
|
||||||
|
<!-- Backdrop — real <button> so keyboard / switch-control users get parity, matching
|
||||||
|
ContextSheet / ConfirmSheet / UploadSheet. The lightbox was the one overlay in the app
|
||||||
|
that did NOT close on an outside tap, which is why it felt broken: every other sheet
|
||||||
|
does. `tabindex="-1"` keeps it out of the tab order, so it adds no stop in front of the
|
||||||
|
photo; Escape still closes via `focusTrap` and the labelled ✕ stays the keyboard route.
|
||||||
|
It sits behind the content, which is `relative` for exactly that reason. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute inset-0 cursor-default"
|
||||||
|
onclick={onclose}
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label="Schließen"
|
||||||
|
></button>
|
||||||
<div
|
<div
|
||||||
class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900"
|
class="relative flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900"
|
||||||
>
|
>
|
||||||
<!-- Media -->
|
<!-- Media -->
|
||||||
<div class="relative bg-black">
|
<div class="relative bg-black">
|
||||||
<button
|
<button
|
||||||
|
bind:this={closeBtn}
|
||||||
onclick={onclose}
|
onclick={onclose}
|
||||||
aria-label="Schließen"
|
aria-label="Schließen"
|
||||||
class="absolute right-2 top-2 z-10 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
class="absolute right-2 top-2 z-10 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
||||||
@@ -185,7 +352,59 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="relative" use:doubletap ondoubletap={triggerHeartBurst}>
|
|
||||||
|
<!-- Chevrons sit OUTSIDE the doubletap wrapper below, so tapping one can never be
|
||||||
|
read as half of a double-tap and fire a like on the photo you are leaving. -->
|
||||||
|
{#if hasPrev}
|
||||||
|
<button
|
||||||
|
bind:this={prevBtn}
|
||||||
|
type="button"
|
||||||
|
onclick={() => void step(-1)}
|
||||||
|
aria-label="Vorheriges Foto"
|
||||||
|
class="absolute left-2 top-1/2 z-10 inline-flex min-h-11 min-w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15.75 19.5 8.25 12l7.5-7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if hasNext}
|
||||||
|
<button
|
||||||
|
bind:this={nextBtn}
|
||||||
|
type="button"
|
||||||
|
onclick={() => void step(1)}
|
||||||
|
aria-label="Nächstes Foto"
|
||||||
|
class="absolute right-2 top-1/2 z-10 inline-flex min-h-11 min-w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- `role="group"` is here for the touch handlers, not for semantics: swipe is a
|
||||||
|
redundant shortcut for the labelled chevrons above and the arrow keys, so
|
||||||
|
assistive tech loses nothing by treating this as a plain grouping. -->
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
role="group"
|
||||||
|
aria-label="Foto – wischen zum Blättern"
|
||||||
|
use:doubletap
|
||||||
|
ondoubletap={triggerHeartBurst}
|
||||||
|
ontouchstart={onTouchStart}
|
||||||
|
ontouchend={onTouchEnd}
|
||||||
|
ontouchcancel={() => (touchTracking = false)}
|
||||||
|
>
|
||||||
{#if isVideo(upload.mime_type)}
|
{#if isVideo(upload.mime_type)}
|
||||||
<video
|
<video
|
||||||
src={mediaSrc}
|
src={mediaSrc}
|
||||||
@@ -195,12 +414,29 @@
|
|||||||
class="max-h-[60vh] w-full object-contain"
|
class="max-h-[60vh] w-full object-contain"
|
||||||
poster={upload.thumbnail_url ?? undefined}
|
poster={upload.thumbnail_url ?? undefined}
|
||||||
></video>
|
></video>
|
||||||
|
{:else if mediaFailed}
|
||||||
|
<!-- Both attempts failed. Anything is better than the empty black box this used
|
||||||
|
to leave behind, which read as a broken app rather than a missing file. -->
|
||||||
|
<div
|
||||||
|
class="flex h-[40vh] flex-col items-center justify-center gap-2 text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<svg class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">Bild konnte nicht geladen werden.</p>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<img
|
<img
|
||||||
src={mediaSrc}
|
src={displaySrc}
|
||||||
alt=""
|
alt=""
|
||||||
class="max-h-[60vh] w-full object-contain select-none"
|
class="max-h-[60vh] w-full select-none object-contain"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
onerror={handleMediaError}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -211,18 +447,20 @@
|
|||||||
<!-- Info + Comments -->
|
<!-- Info + Comments -->
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
<div class="border-b border-gray-100 p-3 dark:border-gray-800">
|
<div class="border-b border-gray-100 p-3 dark:border-gray-800">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div>
|
<!-- `min-w-0` + `truncate`: without them a long display name (guests type their
|
||||||
<span id="lightbox-title" class="font-medium text-gray-900 dark:text-gray-100"
|
own) is unshrinkable and pushes the like button off the right edge. -->
|
||||||
|
<div class="flex min-w-0 items-baseline gap-2">
|
||||||
|
<span id="lightbox-title" class="truncate font-medium text-gray-900 dark:text-gray-100"
|
||||||
>{upload.uploader_name}</span
|
>{upload.uploader_name}</span
|
||||||
>
|
>
|
||||||
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500"
|
<span class="shrink-0 text-xs text-gray-400 dark:text-gray-500"
|
||||||
>{formatTime(upload.created_at)}</span
|
>{formatTime(upload.created_at)}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={() => onlike(upload.id)}
|
onclick={() => onlike(upload.id)}
|
||||||
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {upload.liked_by_me
|
class="flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {upload.liked_by_me
|
||||||
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
|
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
|
||||||
>
|
>
|
||||||
@@ -269,22 +507,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if comment.user_id === userId || $isStaff}
|
{#if comment.user_id === userId || $isStaff}
|
||||||
|
<!-- A trash glyph, not a ✕ (which reads as "dismiss"), and a real 44px tap
|
||||||
|
target: the old 14px icon sat ~4px from the comment text, so a thumb
|
||||||
|
aiming at the text destroyed the comment instead. -->
|
||||||
<button
|
<button
|
||||||
onclick={() => deleteComment(comment.id, comment.user_id !== userId)}
|
type="button"
|
||||||
class="shrink-0 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
onclick={() =>
|
||||||
aria-label={comment.user_id === userId ? 'Löschen' : 'Kommentar entfernen'}
|
(pendingCommentDelete = {
|
||||||
|
id: comment.id,
|
||||||
|
asHost: comment.user_id !== userId
|
||||||
|
})}
|
||||||
|
class="-m-1.5 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||||
|
aria-label={comment.user_id === userId
|
||||||
|
? 'Kommentar löschen'
|
||||||
|
: 'Kommentar entfernen'}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-3.5 w-3.5"
|
class="h-4 w-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -335,3 +583,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Same branded confirmation the post-delete flow uses. -->
|
||||||
|
<ConfirmSheet
|
||||||
|
open={pendingCommentDelete !== null}
|
||||||
|
title={pendingCommentDelete?.asHost ? 'Kommentar entfernen?' : 'Kommentar löschen?'}
|
||||||
|
message={pendingCommentDelete?.asHost
|
||||||
|
? 'Der Kommentar 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={pendingCommentDelete?.asHost ? 'Entfernen' : 'Löschen'}
|
||||||
|
tone="danger"
|
||||||
|
onConfirm={confirmDeleteComment}
|
||||||
|
onCancel={() => (pendingCommentDelete = null)}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import type { FeedUpload } from '$lib/types';
|
import type { FeedUpload } from '$lib/types';
|
||||||
import { dataMode } from '$lib/data-mode-store';
|
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
|
||||||
import { commentsEnabled } from '$lib/event-config-store';
|
import { commentsEnabled } from '$lib/event-config-store';
|
||||||
import { longpress } from '$lib/actions/longpress';
|
import { longpress } from '$lib/actions/longpress';
|
||||||
import FeedListCard from './FeedListCard.svelte';
|
import FeedListCard from './FeedListCard.svelte';
|
||||||
@@ -62,12 +62,74 @@
|
|||||||
return mime.startsWith('video/');
|
return mime.startsWith('video/');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid tiles always use the small thumbnail — full media is one tap away in the
|
// Grid tiles prefer the SMALLEST derivative — three across, so a thumbnail is plenty and
|
||||||
// lightbox where the data-mode picker decides for real.
|
// the full file is one tap away in the lightbox.
|
||||||
|
//
|
||||||
|
// Everything past that preference defers to `pickMediaUrl`, the centralised rule the card
|
||||||
|
// and the lightbox already follow (saver → preview → thumbnail → original). This used to
|
||||||
|
// return '' when no derivative existed yet, which rendered the grey "broken image" tile
|
||||||
|
// for every upload still in the compression queue. Because the feed is newest-first, that
|
||||||
|
// was precisely the top of the grid during a burst — the photos people had just taken —
|
||||||
|
// while the same items opened fine in the lightbox, which has always had the fallback.
|
||||||
|
// An upload with no derivatives is a TRANSIENT state (SSE `upload-processed` swaps in the
|
||||||
|
// preview as soon as the worker finishes), so the original is only ever fetched for the
|
||||||
|
// short window before compression catches up.
|
||||||
function tileUrl(upload: FeedUpload): string {
|
function tileUrl(upload: FeedUpload): string {
|
||||||
if (upload.thumbnail_url) return upload.thumbnail_url;
|
// VIDEOS: a poster image or nothing. `pickMediaUrl` is deliberately mime-agnostic and
|
||||||
if (upload.preview_url) return upload.preview_url;
|
// bottoms out at `/original` — for a video that is the raw MP4, and this is an <img>.
|
||||||
return $dataMode === 'original' ? `/api/v1/upload/${upload.id}/original` : '';
|
// Videos also never get a `preview_url` (compression.rs gives them a thumbnail only,
|
||||||
|
// and only when a poster frame was actually extracted), so falling through would put a
|
||||||
|
// multi-hundred-MB file behind three broken-image tiles per row. Returning '' keeps the
|
||||||
|
// `{#if tileUrl(...)}` guard meaningful and leaves the play icon on its own. This is the
|
||||||
|
// same carve-out LightboxModal documents for the same reason.
|
||||||
|
if (isVideo(upload.mime_type)) return upload.thumbnail_url ?? '';
|
||||||
|
if ($dataMode === 'saver' && upload.thumbnail_url) return upload.thumbnail_url;
|
||||||
|
return pickMediaUrl($dataMode, upload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tile media fallback ──────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A tile whose <img> 404s used to render as an empty grey box with `alt=""` — no icon,
|
||||||
|
// no message, nothing to tap. And it is not an exotic case: `tileUrl` bottoms out at
|
||||||
|
// `/original` while an upload is still compressing, which in a newest-first grid is the
|
||||||
|
// top-left corner during every burst. That also makes the cause usually TRANSIENT, so
|
||||||
|
// one delayed retry recovers most of them; the nonce is required because the browser
|
||||||
|
// would otherwise replay its cached failure instead of re-requesting.
|
||||||
|
//
|
||||||
|
// Keyed by upload id rather than per-<img>, because the virtualizer recycles rows: a
|
||||||
|
// tile that scrolls out and back must not restart the whole dance, and must not lose a
|
||||||
|
// verdict we already reached. The recorded `url` is what makes that safe to keep — an
|
||||||
|
// `upload-processed` that swaps the original for a real preview is a DIFFERENT url and
|
||||||
|
// gets a clean attempt.
|
||||||
|
const MEDIA_RETRY_MS = 4000;
|
||||||
|
type TileError = { url: string; phase: 'retrying' | 'failed'; nonce: number };
|
||||||
|
let tileErrors = $state<Record<string, TileError>>({});
|
||||||
|
|
||||||
|
function tileSrc(upload: FeedUpload): string {
|
||||||
|
const base = tileUrl(upload);
|
||||||
|
if (!base) return '';
|
||||||
|
const e = tileErrors[upload.id];
|
||||||
|
if (!e || e.url !== base) return base;
|
||||||
|
if (e.phase === 'failed') return '';
|
||||||
|
return e.nonce ? `${base}${base.includes('?') ? '&' : '?'}r=${e.nonce}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTileError(upload: FeedUpload) {
|
||||||
|
const base = tileUrl(upload);
|
||||||
|
if (!base) return;
|
||||||
|
const e = tileErrors[upload.id];
|
||||||
|
if (e && e.url === base) {
|
||||||
|
// The retry failed too — show the placeholder rather than a blank tile.
|
||||||
|
tileErrors[upload.id] = { ...e, phase: 'failed' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tileErrors[upload.id] = { url: base, phase: 'retrying', nonce: 0 };
|
||||||
|
setTimeout(() => {
|
||||||
|
const cur = tileErrors[upload.id];
|
||||||
|
if (cur && cur.url === base && cur.phase === 'retrying') {
|
||||||
|
tileErrors[upload.id] = { ...cur, nonce: Date.now() };
|
||||||
|
}
|
||||||
|
}, MEDIA_RETRY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// STABLE option callbacks — created once, never swapped. They read the live
|
// STABLE option callbacks — created once, never swapped. They read the live
|
||||||
@@ -208,13 +270,14 @@
|
|||||||
>
|
>
|
||||||
{#if isVideo(upload.mime_type)}
|
{#if isVideo(upload.mime_type)}
|
||||||
<div class="flex h-full items-center justify-center bg-gray-800">
|
<div class="flex h-full items-center justify-center bg-gray-800">
|
||||||
{#if tileUrl(upload)}
|
{#if tileSrc(upload)}
|
||||||
<img
|
<img
|
||||||
src={tileUrl(upload)}
|
src={tileSrc(upload)}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
onerror={() => handleTileError(upload)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
@@ -227,13 +290,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if tileUrl(upload)}
|
{:else if tileSrc(upload)}
|
||||||
<img
|
<img
|
||||||
src={tileUrl(upload)}
|
src={tileSrc(upload)}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
onerror={() => handleTileError(upload)}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex h-full items-center justify-center text-gray-400">
|
<div class="flex h-full items-center justify-center text-gray-400">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* paint the right colours before the JS bundle loads (no flash of the default palette).
|
* paint the right colours before the JS bundle loads (no flash of the default palette).
|
||||||
*/
|
*/
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
|
import { api } from './api';
|
||||||
import { buildPaletteCss, applyPaletteCss, type ThemeConfig } from './theme/palette';
|
import { buildPaletteCss, applyPaletteCss, type ThemeConfig } from './theme/palette';
|
||||||
|
|
||||||
export const PALETTE_CACHE_KEY = 'eventsnap_palette_css';
|
export const PALETTE_CACHE_KEY = 'eventsnap_palette_css';
|
||||||
@@ -23,12 +24,24 @@ export const eventConfig = writable<EventConfig | null>(null);
|
|||||||
/** Convenience flag for the comment UI. Optimistic `true` until /event resolves. */
|
/** Convenience flag for the comment UI. Optimistic `true` until /event resolves. */
|
||||||
export const commentsEnabled = writable<boolean>(true);
|
export const commentsEnabled = writable<boolean>(true);
|
||||||
|
|
||||||
|
type PublicEventDto = {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
comments_enabled?: boolean;
|
||||||
|
theme_preset?: string;
|
||||||
|
theme_primary?: string;
|
||||||
|
theme_accent?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** Fetch /event, apply the theme, and cache the resolved CSS for the next boot. */
|
/** Fetch /event, apply the theme, and cache the resolved CSS for the next boot. */
|
||||||
export async function loadEventConfig(): Promise<void> {
|
export async function loadEventConfig(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/event');
|
// Through `api` rather than a bare `fetch`: this was the ONE request in the app with
|
||||||
if (!res.ok) return;
|
// no AbortController deadline. On a congested venue WiFi a socket that never answers
|
||||||
const b = await res.json();
|
// left the promise pending indefinitely, so the palette never reconciled with the
|
||||||
|
// server — and since `event-updated` re-calls this, those zombie requests stack up.
|
||||||
|
// `api` aborts at 20s and turns it into an ApiError the catch below already handles.
|
||||||
|
const b = await api.get<PublicEventDto>('/event');
|
||||||
const theme: ThemeConfig = {
|
const theme: ThemeConfig = {
|
||||||
preset: b.theme_preset ?? 'champagne-gold',
|
preset: b.theme_preset ?? 'champagne-gold',
|
||||||
primary: b.theme_primary ?? '#8a6a2b',
|
primary: b.theme_primary ?? '#8a6a2b',
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { filterUploads, type FeedFilter } from './feed-filter';
|
|
||||||
|
|
||||||
// filterUploads only reads `caption` and `uploader_name`, so a partial shape suffices.
|
|
||||||
const u = (id: string, uploader_name: string, caption: string | null) =>
|
|
||||||
({ id, uploader_name, caption }) as any;
|
|
||||||
|
|
||||||
const uploads = [
|
|
||||||
u('1', 'Alice', 'pic #wedding'),
|
|
||||||
u('2', 'Bob', 'party #party'),
|
|
||||||
u('3', 'Alice', 'more #wedding #party'),
|
|
||||||
u('4', 'Carol', 'no tags here'),
|
|
||||||
u('5', 'Bob', null) // no caption
|
|
||||||
];
|
|
||||||
|
|
||||||
const ids = (r: any[]) => r.map((x) => x.id);
|
|
||||||
const tag = (value: string): FeedFilter => ({ type: 'tag', value });
|
|
||||||
const user = (value: string): FeedFilter => ({ type: 'user', value });
|
|
||||||
|
|
||||||
describe('filterUploads', () => {
|
|
||||||
it('no filters → returns all uploads unchanged', () => {
|
|
||||||
expect(filterUploads(uploads, [])).toBe(uploads);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a single tag matches uploads whose caption contains it', () => {
|
|
||||||
expect(ids(filterUploads(uploads, [tag('wedding')]))).toEqual(['1', '3']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('two tags combine with OR', () => {
|
|
||||||
expect(ids(filterUploads(uploads, [tag('wedding'), tag('party')]))).toEqual(['1', '2', '3']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a single user matches only that uploader', () => {
|
|
||||||
expect(ids(filterUploads(uploads, [user('Alice')]))).toEqual(['1', '3']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('two users combine with OR', () => {
|
|
||||||
expect(ids(filterUploads(uploads, [user('Alice'), user('Bob')]))).toEqual(['1', '2', '3', '5']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a user chip and a tag chip combine with AND', () => {
|
|
||||||
// Alice AND #wedding → only Alice's wedding uploads (not Bob's #wedding, not Alice's #party-only)
|
|
||||||
expect(ids(filterUploads(uploads, [user('Alice'), tag('wedding')]))).toEqual(['1', '3']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('AND excludes an uploader-match that lacks the tag', () => {
|
|
||||||
// Bob AND #wedding → none (Bob has #party and a null caption, no #wedding)
|
|
||||||
expect(filterUploads(uploads, [user('Bob'), tag('wedding')])).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tag matching is case-insensitive against the caption', () => {
|
|
||||||
expect(filterUploads([u('9', 'X', 'PIC #WeDDing')], [tag('wedding')])).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a null caption never matches a tag but can match a user', () => {
|
|
||||||
expect(filterUploads([u('5', 'Bob', null)], [tag('party')])).toHaveLength(0);
|
|
||||||
expect(filterUploads([u('5', 'Bob', null)], [user('Bob')])).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
// Grid-view feed filtering. Extracted from feed/+page.svelte so the OR/AND
|
|
||||||
// combination rules are unit-testable without mounting the page.
|
|
||||||
|
|
||||||
import type { FeedUpload } from './types';
|
|
||||||
|
|
||||||
export interface FeedFilter {
|
|
||||||
type: 'tag' | 'user';
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply the active grid filters to a list of uploads.
|
|
||||||
*
|
|
||||||
* Combination rules (mirroring the chip UI):
|
|
||||||
* - Tags combine with **OR**: a card passes the tag group if its caption contains
|
|
||||||
* ANY selected `#tag`.
|
|
||||||
* - Users combine with **OR** within the user group (uploader is one of the
|
|
||||||
* selected names).
|
|
||||||
* - The tag group and the user group combine with **AND**: a card must satisfy
|
|
||||||
* both groups. An empty group is a pass-through.
|
|
||||||
*
|
|
||||||
* Tags are matched against the caption text (the autocomplete source), so `value`
|
|
||||||
* is expected lowercase (as produced by the tag suggestions).
|
|
||||||
*/
|
|
||||||
export function filterUploads(uploads: FeedUpload[], filters: FeedFilter[]): FeedUpload[] {
|
|
||||||
if (filters.length === 0) return uploads;
|
|
||||||
const tags = filters.filter((f) => f.type === 'tag').map((f) => f.value);
|
|
||||||
const users = filters.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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
import { onClearAuth } from './auth';
|
||||||
|
|
||||||
export interface QuotaSnapshot {
|
export interface QuotaSnapshot {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -26,6 +27,12 @@ const empty: QuotaSnapshot = {
|
|||||||
|
|
||||||
export const quotaStore = writable<QuotaSnapshot>(empty);
|
export const quotaStore = writable<QuotaSnapshot>(empty);
|
||||||
|
|
||||||
|
// Reset on logout, the same way role-store / ban-store / upload-queue do. A phone handed
|
||||||
|
// round a party is one browser profile: `goto()` is a client-side navigation, so no module
|
||||||
|
// re-imports and no `onMount` re-runs, and without this the next guest saw the PREVIOUS
|
||||||
|
// guest's storage usage — their data, on their screen — until the first refresh landed.
|
||||||
|
onClearAuth(() => quotaStore.set(empty));
|
||||||
|
|
||||||
/** Refresh from the server. Swallows errors so a transient network blip doesn't
|
/** Refresh from the server. Swallows errors so a transient network blip doesn't
|
||||||
* break the account page; the previous snapshot just stays in place. */
|
* break the account page; the previous snapshot just stays in place. */
|
||||||
export async function refreshQuota(): Promise<void> {
|
export async function refreshQuota(): Promise<void> {
|
||||||
|
|||||||
@@ -27,6 +27,27 @@ const handlers: Map<string, EventHandler[]> = new Map();
|
|||||||
let reconnectAttempt = 0;
|
let reconnectAttempt = 0;
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True once a stream has opened at least once this session. Distinguishes "first
|
||||||
|
* connect" from "reconnect" now that `lastEventTime` is seeded at ticket-mint rather
|
||||||
|
* than in `onopen` (see `connectSse`) — without it every boot would fire a pointless
|
||||||
|
* zero-width delta.
|
||||||
|
*/
|
||||||
|
let streamEverOpened = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Date.now()` of the last thing we actually received on the live stream.
|
||||||
|
*
|
||||||
|
* Keep-alives are deliberately NOT observable here: the backend sends them as SSE
|
||||||
|
* COMMENTS (`KeepAlive::new().text("ping")` emits `:ping`), and the EventSource parser
|
||||||
|
* discards comments without dispatching anything at all. There is no browser API that
|
||||||
|
* exposes them. So liveness cannot be decided by a plain silence timer — it could not
|
||||||
|
* tell a dead socket from a genuinely quiet half hour, and would churn reconnects for
|
||||||
|
* every guest through every lull. The backstop poll below decides it on evidence
|
||||||
|
* instead: the server had news that this stream never delivered.
|
||||||
|
*/
|
||||||
|
let lastStreamActivity = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSE event names emitted by the backend. Add new ones here as `state.sse_tx.send`
|
* SSE event names emitted by the backend. Add new ones here as `state.sse_tx.send`
|
||||||
* call sites grow — every entry becomes a relay registration below.
|
* call sites grow — every entry becomes a relay registration below.
|
||||||
@@ -91,29 +112,40 @@ export function connectSse(): void {
|
|||||||
scheduleReconnect();
|
scheduleReconnect();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Auth flow may have torn things down while we were awaiting the ticket.
|
// Seed the reconnect cursor the moment we have a server clock — NOT in `onopen`,
|
||||||
if (!getToken() || eventSource) return;
|
// which is where it used to live. `onopen` never fires behind a captive portal or
|
||||||
|
// any proxy that buffers `text/event-stream`, so on exactly the networks where the
|
||||||
|
// stream fails the cursor stayed `null` forever and the backstop poll below had no
|
||||||
|
// `since` to fetch from. Still the server clock and never `new Date()`: a skewed
|
||||||
|
// browser clock would shift the window and silently drop uploads.
|
||||||
|
if (!lastEventTime) lastEventTime = serverTime;
|
||||||
|
|
||||||
|
// Auth flow may have torn things down while we were awaiting the ticket — and the
|
||||||
|
// phone may have gone to sleep during that round-trip. Opening a stream while
|
||||||
|
// hidden is worse than not opening one: iOS reaps a backgrounded socket without
|
||||||
|
// ever firing `onerror`, so `eventSource` stays non-null and the guard at the top
|
||||||
|
// of this function then treats the corpse as a live connection for the rest of the
|
||||||
|
// evening. The visibility handler reconnects us when the screen comes back.
|
||||||
|
if (!getToken() || eventSource || (typeof document !== 'undefined' && document.hidden)) return;
|
||||||
|
|
||||||
eventSource = new EventSource(`/api/v1/stream?ticket=${encodeURIComponent(ticket)}`);
|
eventSource = new EventSource(`/api/v1/stream?ticket=${encodeURIComponent(ticket)}`);
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
// Successful connection — reset the backoff counter.
|
// Successful connection — reset the backoff counter.
|
||||||
reconnectAttempt = 0;
|
reconnectAttempt = 0;
|
||||||
// If we have a previous timestamp this is a reconnect — fetch the gap. The
|
noteStreamActivity();
|
||||||
// delta advances `lastEventTime` from the SERVER clock it returns.
|
// A reconnect has a gap to close; the very first open of a session does not
|
||||||
const since = lastEventTime;
|
// (the cursor was just seeded from this ticket's server clock). The delta
|
||||||
if (since) {
|
// advances `lastEventTime` from the SERVER clock it returns.
|
||||||
void deltaFetchAndFan(since);
|
if (streamEverOpened && lastEventTime) void deltaFetchAndFan(lastEventTime);
|
||||||
} else {
|
streamEverOpened = true;
|
||||||
// First connect: seed the cursor from the server clock at ticket-mint time,
|
|
||||||
// never `new Date()` — a skewed browser clock would otherwise shift the very
|
|
||||||
// first reconnect window and could drop uploads.
|
|
||||||
lastEventTime = serverTime;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const eventName of KNOWN_EVENTS) {
|
for (const eventName of KNOWN_EVENTS) {
|
||||||
eventSource.addEventListener(eventName, (e) => dispatch(eventName, (e as MessageEvent).data));
|
eventSource.addEventListener(eventName, (e) => {
|
||||||
|
noteStreamActivity();
|
||||||
|
dispatch(eventName, (e as MessageEvent).data);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// `resync` is emitted by the server when our broadcast subscription fell
|
// `resync` is emitted by the server when our broadcast subscription fell
|
||||||
@@ -123,6 +155,7 @@ export function connectSse(): void {
|
|||||||
// own listener — not via `dispatch` — so reading `lastEventTime` as the gap
|
// own listener — not via `dispatch` — so reading `lastEventTime` as the gap
|
||||||
// start isn't clobbered by dispatch bumping it to "now".
|
// start isn't clobbered by dispatch bumping it to "now".
|
||||||
eventSource.addEventListener('resync', () => {
|
eventSource.addEventListener('resync', () => {
|
||||||
|
noteStreamActivity();
|
||||||
const since = lastEventTime;
|
const since = lastEventTime;
|
||||||
if (since) void deltaFetchAndFan(since);
|
if (since) void deltaFetchAndFan(since);
|
||||||
});
|
});
|
||||||
@@ -141,7 +174,14 @@ export function connectSse(): void {
|
|||||||
function scheduleReconnect(): void {
|
function scheduleReconnect(): void {
|
||||||
reconnectAttempt++;
|
reconnectAttempt++;
|
||||||
const delay = Math.min(60_000, 1_000 * 2 ** (reconnectAttempt - 1));
|
const delay = Math.min(60_000, 1_000 * 2 ** (reconnectAttempt - 1));
|
||||||
const jitter = Math.random() * 500;
|
// Jitter must SCALE WITH the backoff, not be a flat 500ms. Every client that dropped
|
||||||
|
// together shares the same `reconnectAttempt`, so they compute an identical `delay` —
|
||||||
|
// a fixed 500ms window spreads 100 phones over half a second no matter how long the
|
||||||
|
// backoff grew, which is the thundering herd the backoff exists to prevent. Each
|
||||||
|
// reconnect costs a ticket POST + stream GET + feed-delta fetch, so the herd lands on
|
||||||
|
// the DB pool three times over. Scaling the jitter to the delay (floored at 1s so the
|
||||||
|
// first, most synchronised retry is spread too) turns that into a smooth ramp.
|
||||||
|
const jitter = Math.random() * Math.max(delay, 1_000);
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
reconnectTimer = setTimeout(connectSse, delay + jitter);
|
reconnectTimer = setTimeout(connectSse, delay + jitter);
|
||||||
}
|
}
|
||||||
@@ -157,6 +197,91 @@ export function disconnectSse(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function noteStreamActivity(): void {
|
||||||
|
lastStreamActivity = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stream backstop ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// EVERY feed update is triggered by an SSE event — there is no periodic refetch — so a
|
||||||
|
// stream that stops delivering freezes a guest's gallery for the rest of the evening.
|
||||||
|
// And a venue produces exactly the two failures that the reconnect path cannot see:
|
||||||
|
//
|
||||||
|
// • a captive portal or any proxy that buffers `text/event-stream` never forwards a
|
||||||
|
// byte, so `onopen` may never fire and neither does `onerror`;
|
||||||
|
// • a phone that roams between APs leaves a HALF-OPEN socket — the connection is gone
|
||||||
|
// but `readyState` still reads OPEN, no error is raised, and `connectSse` early-
|
||||||
|
// returns on its non-null `eventSource`, so nothing ever reconnects.
|
||||||
|
//
|
||||||
|
// Neither surfaces anything to react to, which is why the only honest backstop is to
|
||||||
|
// ask the server. `/feed/delta` is idempotent and answers with an empty payload when
|
||||||
|
// nothing changed, and one request per 60–120s is ~1/60th of the per-user feed limit —
|
||||||
|
// cheap insurance against a guest staring at a frozen feed all night. The diashow uses
|
||||||
|
// the same reconcile-on-a-timer for the same reason (`RECONCILE_INTERVAL_MS` there).
|
||||||
|
const BACKSTOP_MIN_MS = 60_000;
|
||||||
|
const BACKSTOP_MAX_MS = 120_000;
|
||||||
|
let backstopTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let backstopEnabled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the poll-based liveness/completeness backstop. Opt-in per page (the feed is the
|
||||||
|
* consumer that needs it; the export page's SSE use is a status ping and the diashow
|
||||||
|
* runs its own full reconcile), and idempotent.
|
||||||
|
*/
|
||||||
|
export function startStreamBackstop(): void {
|
||||||
|
if (typeof document === 'undefined' || backstopEnabled) return;
|
||||||
|
backstopEnabled = true;
|
||||||
|
scheduleBackstop();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopStreamBackstop(): void {
|
||||||
|
backstopEnabled = false;
|
||||||
|
if (backstopTimer) {
|
||||||
|
clearTimeout(backstopTimer);
|
||||||
|
backstopTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBackstop(): void {
|
||||||
|
if (backstopTimer) clearTimeout(backstopTimer);
|
||||||
|
// Jittered for the same reason the reconnect backoff is: ~100 phones that joined
|
||||||
|
// within the same few minutes would otherwise poll in permanent lockstep.
|
||||||
|
const delay = BACKSTOP_MIN_MS + Math.random() * (BACKSTOP_MAX_MS - BACKSTOP_MIN_MS);
|
||||||
|
backstopTimer = setTimeout(() => void runBackstop(), delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBackstop(): Promise<void> {
|
||||||
|
backstopTimer = null;
|
||||||
|
try {
|
||||||
|
// A hidden tab has no stream (the visibility handler closed it) and cannot show a
|
||||||
|
// result anyway; the reopen path already fetches the gap.
|
||||||
|
if (document.hidden || !getToken()) return;
|
||||||
|
// No stream, or one the browser has admitted is closed, while we are visible: there
|
||||||
|
// is no other way back, because `connectSse`'s `eventSource` guard cannot tell a
|
||||||
|
// corpse from a live connection.
|
||||||
|
if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
|
||||||
|
disconnectSse();
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
connectSse();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const since = lastEventTime;
|
||||||
|
if (!since) return;
|
||||||
|
const activityBefore = lastStreamActivity;
|
||||||
|
const carried = await deltaFetchAndFan(since);
|
||||||
|
// The server had news that this stream never delivered. That is the evidence a
|
||||||
|
// half-open socket cannot otherwise give us — reconnect, or every remaining update
|
||||||
|
// tonight arrives at poll latency instead of instantly.
|
||||||
|
if (carried && lastStreamActivity === activityBefore) {
|
||||||
|
disconnectSse();
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
connectSse();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (backstopEnabled) scheduleBackstop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getLastEventTime(): string | null {
|
export function getLastEventTime(): string | null {
|
||||||
return lastEventTime;
|
return lastEventTime;
|
||||||
}
|
}
|
||||||
@@ -196,14 +321,23 @@ function extractCreatedAt(data: string): string | undefined {
|
|||||||
* event. Subscribers (typically the feed page) merge the result into their
|
* event. Subscribers (typically the feed page) merge the result into their
|
||||||
* in-memory list. Swallows errors — a failed delta is non-fatal; the next live
|
* in-memory list. Swallows errors — a failed delta is non-fatal; the next live
|
||||||
* SSE event will keep the feed moving.
|
* SSE event will keep the feed moving.
|
||||||
|
*
|
||||||
|
* Resolves to whether the delta actually CARRIED something. `runBackstop` uses that as
|
||||||
|
* its liveness signal: content the poll found but the stream never pushed means the
|
||||||
|
* stream is dead in the way the browser will not report.
|
||||||
*/
|
*/
|
||||||
async function deltaFetchAndFan(since: string, attempt = 0): Promise<void> {
|
async function deltaFetchAndFan(since: string, attempt = 0): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get<DeltaResponse>(`/feed/delta?since=${encodeURIComponent(since)}`);
|
const response = await api.get<DeltaResponse>(`/feed/delta?since=${encodeURIComponent(since)}`);
|
||||||
// Advance the cursor to the server clock this delta was computed at, so the next
|
// Advance the cursor to the server clock this delta was computed at, so the next
|
||||||
// reconnect resumes exactly where the server left off (no browser-clock skew).
|
// reconnect resumes exactly where the server left off (no browser-clock skew).
|
||||||
lastEventTime = response.server_time;
|
lastEventTime = response.server_time;
|
||||||
dispatch('feed-delta', JSON.stringify(response));
|
dispatch('feed-delta', JSON.stringify(response));
|
||||||
|
return (
|
||||||
|
response.uploads.length > 0 ||
|
||||||
|
response.deleted_ids.length > 0 ||
|
||||||
|
response.hidden_user_ids.length > 0
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// A throttled delta (429) must NOT be silently dropped: live events keep advancing
|
// A throttled delta (429) must NOT be silently dropped: live events keep advancing
|
||||||
// `lastEventTime`, so the next reconnect would resume PAST this un-fetched gap and
|
// `lastEventTime`, so the next reconnect would resume PAST this un-fetched gap and
|
||||||
@@ -211,10 +345,15 @@ async function deltaFetchAndFan(since: string, attempt = 0): Promise<void> {
|
|||||||
// covered regardless of how the live cursor moves in the meantime. Bounded, and only
|
// covered regardless of how the live cursor moves in the meantime. Bounded, and only
|
||||||
// reachable by a rapidly flapping EventSource hitting the per-user delta limit.
|
// reachable by a rapidly flapping EventSource hitting the per-user delta limit.
|
||||||
if (e instanceof ApiError && e.status === 429 && attempt < MAX_DELTA_RETRIES) {
|
if (e instanceof ApiError && e.status === 429 && attempt < MAX_DELTA_RETRIES) {
|
||||||
const delayMs = DELTA_RETRY_BASE_MS * 2 ** attempt;
|
// Jittered for the same reason `scheduleReconnect` is: the clients that hit this
|
||||||
setTimeout(() => void deltaFetchAndFan(since, attempt + 1), delayMs);
|
// 429 are the ones that just reconnected together after a venue-wide wifi blip,
|
||||||
|
// so they share `attempt` and would otherwise retry in lockstep at exactly 2s,
|
||||||
|
// 4s, 8s — re-tripping the same per-user limit in a synchronised wave.
|
||||||
|
const base = DELTA_RETRY_BASE_MS * 2 ** attempt;
|
||||||
|
setTimeout(() => void deltaFetchAndFan(since, attempt + 1), base + Math.random() * base);
|
||||||
}
|
}
|
||||||
// Other errors are non-fatal — the next live SSE event keeps the feed moving.
|
// Other errors are non-fatal — the next live SSE event keeps the feed moving.
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +367,12 @@ function handleVisibilityChange() {
|
|||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
disconnectSse();
|
disconnectSse();
|
||||||
} else {
|
} else {
|
||||||
|
// Tear down UNCONDITIONALLY before reconnecting rather than leaning on
|
||||||
|
// `connectSse`'s `eventSource` guard. iOS can reap a backgrounded socket without
|
||||||
|
// ever firing `onerror`, which leaves a non-null but permanently dead EventSource —
|
||||||
|
// and the guard would then read that as "already connected" and never reconnect,
|
||||||
|
// for the rest of the evening. Closing an already-closed EventSource is a no-op.
|
||||||
|
disconnectSse();
|
||||||
// User-initiated reconnect — clear backoff so we don't wait out a long
|
// User-initiated reconnect — clear backoff so we don't wait out a long
|
||||||
// retry delay that was scheduled from a prior background error.
|
// retry delay that was scheduled from a prior background error.
|
||||||
reconnectAttempt = 0;
|
reconnectAttempt = 0;
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export interface MeContextDto {
|
|||||||
storage_quota_enabled: boolean;
|
storage_quota_enabled: boolean;
|
||||||
uploads_locked: boolean;
|
uploads_locked: boolean;
|
||||||
gallery_released: boolean;
|
gallery_released: boolean;
|
||||||
|
/** Read-only ban: the feed and the keepsake stay available, every write is refused. */
|
||||||
|
is_banned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// mirrors backend/src/handlers/host.rs::PinResetResponse
|
// mirrors backend/src/handlers/host.rs::PinResetResponse
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
import { getToken, getUserId } from '$lib/auth';
|
import { getToken, getUserId } from '$lib/auth';
|
||||||
import { isStaff } from '$lib/role-store';
|
import { isStaff } from '$lib/role-store';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
|
import {
|
||||||
|
connectSse,
|
||||||
|
disconnectSse,
|
||||||
|
onSseEvent,
|
||||||
|
startStreamBackstop,
|
||||||
|
stopStreamBackstop
|
||||||
|
} from '$lib/sse';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import VirtualFeed from '$lib/components/VirtualFeed.svelte';
|
import VirtualFeed from '$lib/components/VirtualFeed.svelte';
|
||||||
import HashtagChips from '$lib/components/HashtagChips.svelte';
|
import HashtagChips from '$lib/components/HashtagChips.svelte';
|
||||||
@@ -17,7 +23,6 @@
|
|||||||
import { toast, toastError } from '$lib/toast-store';
|
import { toast, toastError } from '$lib/toast-store';
|
||||||
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
|
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
|
||||||
import { vibrate } from '$lib/haptics';
|
import { vibrate } from '$lib/haptics';
|
||||||
import { filterUploads } from '$lib/feed-filter';
|
|
||||||
import { refreshEventState } from '$lib/event-state-store';
|
import { refreshEventState } from '$lib/event-state-store';
|
||||||
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
|
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
|
||||||
|
|
||||||
@@ -27,6 +32,11 @@
|
|||||||
let nextCursor = $state<string | null>(null);
|
let nextCursor = $state<string | null>(null);
|
||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
let initialLoading = $state(true);
|
let initialLoading = $state(true);
|
||||||
|
// Set when a load left us with NOTHING to show. Without it the template fell straight
|
||||||
|
// through to "Noch keine Fotos" — so on the venue WiFi the most likely first thing a
|
||||||
|
// guest ever saw was the app confidently telling them the gallery was empty, with no
|
||||||
|
// error, no retry, and nobody around to ask.
|
||||||
|
let loadError = $state(false);
|
||||||
let refreshing = $state(false);
|
let refreshing = $state(false);
|
||||||
let pullProgress = $state(0); // 0–1+ during the drag, 0 when idle
|
let pullProgress = $state(0); // 0–1+ during the drag, 0 when idle
|
||||||
let selectedUpload = $state<FeedUpload | null>(null);
|
let selectedUpload = $state<FeedUpload | null>(null);
|
||||||
@@ -36,6 +46,10 @@
|
|||||||
let sentinel: HTMLDivElement;
|
let sentinel: HTMLDivElement;
|
||||||
let feedObserver: IntersectionObserver | null = null;
|
let feedObserver: IntersectionObserver | null = null;
|
||||||
let inPlaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
let inPlaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// Latest moment the coalescing window below is allowed to push the reconcile to, and
|
||||||
|
// a "an event arrived while hidden" flag — see `scheduleInPlaceRefresh`.
|
||||||
|
let inPlaceRefreshDeadline = 0;
|
||||||
|
let inPlaceRefreshDeferred = false;
|
||||||
// `asHost` picks the endpoint AND the copy: removing someone else's photo is a
|
// `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.
|
// moderation action, not "delete my post", and it hits the host route.
|
||||||
let pendingDelete = $state<{ id: string; asHost: boolean } | null>(null);
|
let pendingDelete = $state<{ id: string; asHost: boolean } | null>(null);
|
||||||
@@ -49,11 +63,20 @@
|
|||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
const prev = document.documentElement.style.overscrollBehaviorY;
|
const prev = document.documentElement.style.overscrollBehaviorY;
|
||||||
document.documentElement.style.overscrollBehaviorY = 'contain';
|
document.documentElement.style.overscrollBehaviorY = 'contain';
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
return () => {
|
return () => {
|
||||||
document.documentElement.style.overscrollBehaviorY = prev;
|
document.documentElement.style.overscrollBehaviorY = prev;
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Run the reconcile that was deferred while the tab was hidden (see `scheduleInPlaceRefresh`). */
|
||||||
|
function handleVisibility() {
|
||||||
|
if (document.hidden || !inPlaceRefreshDeferred) return;
|
||||||
|
inPlaceRefreshDeferred = false;
|
||||||
|
scheduleInPlaceRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
// View mode
|
// View mode
|
||||||
let viewMode = $state<'list' | 'grid'>('list');
|
let viewMode = $state<'list' | 'grid'>('list');
|
||||||
|
|
||||||
@@ -132,20 +155,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Autocomplete derived from loaded uploads (no extra API calls) ────────
|
// ── Autocomplete sources ─────────────────────────────────────────────────
|
||||||
|
// Tags come from the SERVER's hashtag index (`/hashtags`, already loaded for the list
|
||||||
|
// view's chips and ordered by count) — not just the captions of the uploads currently
|
||||||
|
// in memory. Page 1 is 20 items, so deriving tags only from loaded captions meant a tag
|
||||||
|
// used further down the feed was simply absent from the picker until the user happened
|
||||||
|
// to scroll past the photo carrying it. That is the "sometimes I can't select a tag"
|
||||||
|
// case: the tag was never offered, so no amount of typing surfaced it.
|
||||||
|
//
|
||||||
|
// Captions of loaded uploads are unioned in afterwards so a tag from a photo that landed
|
||||||
|
// since the last `/hashtags` refresh is still offered. The regex mirrors the backend rule
|
||||||
|
// exactly — ASCII alphanumerics and `_`, stopping at the first other character (see
|
||||||
|
// backend `models/hashtag.rs::extract_hashtags`) — so both sources agree on what a tag is.
|
||||||
let allTags = $derived.by(() => {
|
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.
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local dedupe set inside a $derived.by; never stored in $state, so no reactivity is involved.
|
||||||
const freq = new Map<string, number>();
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
const add = (raw: string) => {
|
||||||
|
const t = raw.toLowerCase();
|
||||||
|
if (t && !seen.has(t)) {
|
||||||
|
seen.add(t);
|
||||||
|
out.push(t);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const h of hashtags) add(h.tag);
|
||||||
for (const u of uploads) {
|
for (const u of uploads) {
|
||||||
for (const m of (u.caption ?? '').matchAll(/#(\w+)/g)) {
|
for (const m of (u.caption ?? '').matchAll(/#(\w+)/g)) add(m[1]);
|
||||||
const t = m[1].toLowerCase();
|
|
||||||
freq.set(t, (freq.get(t) ?? 0) + 1);
|
|
||||||
}
|
}
|
||||||
}
|
return out;
|
||||||
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());
|
// Uploaders come from `/uploaders` for the same reason tags come from `/hashtags`: deriving
|
||||||
|
// them from the uploads currently in memory meant typing a guest's name found nothing
|
||||||
|
// whenever their photos sat below page 1, which reads as "search is broken". The endpoint
|
||||||
|
// reads v_feed, so banned and hidden uploaders are already excluded.
|
||||||
|
let uploaderNames = $state<string[]>([]);
|
||||||
|
let allUploaders = $derived(uploaderNames);
|
||||||
|
|
||||||
// The suggestion SOURCE is frozen for as long as the dropdown is open.
|
// The suggestion SOURCE is frozen for as long as the dropdown is open.
|
||||||
//
|
//
|
||||||
@@ -186,31 +231,36 @@
|
|||||||
...frozenTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t }))
|
...frozenTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t }))
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
// Once the user has TYPED, every match is offered — no `.slice()`. The old caps (8 for
|
||||||
|
// a `#` query, 4 tags + 4 users otherwise) silently dropped matches, so a tag that
|
||||||
|
// existed and matched what was typed still could not be selected, with nothing on
|
||||||
|
// screen to say more existed. The dropdown scrolls instead (see `max-h-72` below),
|
||||||
|
// which bounds the UI without bounding the choices.
|
||||||
if (q.startsWith('#')) {
|
if (q.startsWith('#')) {
|
||||||
const prefix = q.slice(1).toLowerCase();
|
const prefix = q.slice(1).toLowerCase();
|
||||||
return frozenTags
|
return frozenTags
|
||||||
.filter((t) => t.startsWith(prefix))
|
.filter((t) => t.startsWith(prefix))
|
||||||
.slice(0, 8)
|
|
||||||
.map((t) => ({ type: 'tag' as const, value: t }));
|
.map((t) => ({ type: 'tag' as const, value: t }));
|
||||||
}
|
}
|
||||||
const lower = q.toLowerCase();
|
const lower = q.toLowerCase();
|
||||||
return [
|
return [
|
||||||
...frozenUploaders
|
...frozenUploaders
|
||||||
.filter((u) => u.toLowerCase().includes(lower))
|
.filter((u) => u.toLowerCase().includes(lower))
|
||||||
.slice(0, 4)
|
|
||||||
.map((u) => ({ type: 'user' as const, value: u })),
|
.map((u) => ({ type: 'user' as const, value: u })),
|
||||||
...frozenTags
|
...frozenTags
|
||||||
.filter((t) => t.includes(lower))
|
.filter((t) => t.includes(lower))
|
||||||
.slice(0, 4)
|
|
||||||
.map((t) => ({ type: 'tag' as const, value: t }))
|
.map((t) => ({ type: 'tag' as const, value: t }))
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Filtered uploads for grid view ───────────────────────────────────────
|
// `uploads` IS the filtered set — the server applied the filters (see `filterParams`), so
|
||||||
let displayUploads = $derived.by(() => {
|
// there is nothing left to narrow client-side. The previous client-side pass could only
|
||||||
if (viewMode === 'list' || activeFilters.length === 0) return uploads;
|
// ever see the pages already loaded, so a tag whose photos sat past page 1 rendered a
|
||||||
return filterUploads(uploads, activeFilters);
|
// near-empty grid that looked complete, and it matched a caption SUBSTRING rather than the
|
||||||
});
|
// tag itself (`#tanz` also matched `#tanzflaeche`) — so list and grid disagreed about the
|
||||||
|
// same chip. Kept as an alias so the template and the infinite-scroll sentinel read the
|
||||||
|
// same way in both views.
|
||||||
|
let displayUploads = $derived(uploads);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// onMount B — auth gate, recovery toast, data load, SSE subscriptions,
|
// onMount B — auth gate, recovery toast, data load, SSE subscriptions,
|
||||||
@@ -243,8 +293,12 @@
|
|||||||
// state in the same request.
|
// state in the same request.
|
||||||
void refreshEventState();
|
void refreshEventState();
|
||||||
|
|
||||||
await Promise.all([loadFeed(), loadHashtags()]);
|
await Promise.all([loadFeed(), loadHashtags(), loadUploaders()]);
|
||||||
connectSse();
|
connectSse();
|
||||||
|
// Nothing in this page refetches on a timer — every update path below hangs off an
|
||||||
|
// SSE event. The backstop is what keeps that from meaning "one bad socket and the
|
||||||
|
// gallery is frozen until you force-reload", which is not a thing a guest will do.
|
||||||
|
startStreamBackstop();
|
||||||
|
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
onSseEvent('new-upload', (data) => {
|
onSseEvent('new-upload', (data) => {
|
||||||
@@ -371,6 +425,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
disconnectSse();
|
disconnectSse();
|
||||||
|
stopStreamBackstop();
|
||||||
for (const unsub of unsubscribers) unsub();
|
for (const unsub of unsubscribers) unsub();
|
||||||
feedObserver?.disconnect();
|
feedObserver?.disconnect();
|
||||||
if (inPlaceRefreshTimer) clearTimeout(inPlaceRefreshTimer);
|
if (inPlaceRefreshTimer) clearTimeout(inPlaceRefreshTimer);
|
||||||
@@ -396,64 +451,164 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounced page-1 fetch that *merges* (updates existing cards in place, prepends
|
// Coalescing window for the SSE-driven reconcile. The old floor was `800 + random*2000`,
|
||||||
// genuinely new ones) rather than replacing the array — preserves scroll and any
|
// which during a burst (a bulk upload fires one `upload-processed` PER FILE) meant
|
||||||
// pages already loaded below the fold.
|
// roughly one feed query per client every ~2s. At 100 guests that walks straight into
|
||||||
|
// the backend's 60/min per-user feed limit — and `refreshFeedInPlace`'s bare `catch {}`
|
||||||
|
// swallowed the resulting 429s, so the feed simply stopped updating with nothing on
|
||||||
|
// screen to say why. A reconcile is a background nicety; seconds of latency cost the
|
||||||
|
// guest nothing, while the request budget is the thing that actually runs out.
|
||||||
|
const REFRESH_DEBOUNCE_MS = 8_000;
|
||||||
|
// Spread on top of the floor. A fixed delay would make every client that saw the same
|
||||||
|
// broadcast fetch in the same window — 100 feed queries landing together, on top of the
|
||||||
|
// reconnect burst that often triggered them. The spread makes it a ramp.
|
||||||
|
const REFRESH_SPREAD_MS = 7_000;
|
||||||
|
// Ceiling on the coalescing, so a party that never stops posting still reconciles.
|
||||||
|
const REFRESH_MAX_WAIT_MS = 30_000;
|
||||||
|
// How much of the loaded feed a reconcile re-reads, and at what page size. The server
|
||||||
|
// caps `limit` at 100.
|
||||||
|
const RECONCILE_PAGE = 100;
|
||||||
|
const RECONCILE_MAX_PAGES = 3;
|
||||||
|
|
||||||
|
// Debounced fetch that *merges* (updates existing cards in place, prepends genuinely
|
||||||
|
// new ones) rather than replacing the array — preserves scroll and any pages already
|
||||||
|
// loaded below the fold.
|
||||||
function scheduleInPlaceRefresh() {
|
function scheduleInPlaceRefresh() {
|
||||||
if (inPlaceRefreshTimer) return;
|
// A hidden tab cannot show the result, and iOS clamps its timers into a clump that
|
||||||
|
// all fires at once on wake. Defer to the visibility change, where exactly one runs.
|
||||||
|
if (typeof document !== 'undefined' && document.hidden) {
|
||||||
|
inPlaceRefreshDeferred = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nowMs = Date.now();
|
||||||
|
if (!inPlaceRefreshTimer) inPlaceRefreshDeadline = nowMs + REFRESH_MAX_WAIT_MS;
|
||||||
|
// Push the reconcile out again on every further event, so a burst of thirty uploads
|
||||||
|
// costs ONE feed query once it settles rather than one per event — but never past
|
||||||
|
// the deadline above.
|
||||||
|
const delay = Math.min(
|
||||||
|
REFRESH_DEBOUNCE_MS + Math.random() * REFRESH_SPREAD_MS,
|
||||||
|
Math.max(0, inPlaceRefreshDeadline - nowMs)
|
||||||
|
);
|
||||||
|
if (inPlaceRefreshTimer) clearTimeout(inPlaceRefreshTimer);
|
||||||
inPlaceRefreshTimer = setTimeout(() => {
|
inPlaceRefreshTimer = setTimeout(() => {
|
||||||
inPlaceRefreshTimer = null;
|
inPlaceRefreshTimer = null;
|
||||||
void refreshFeedInPlace();
|
void refreshFeedInPlace().catch(() => {
|
||||||
}, 800);
|
// Background reconcile — quiet by design. The next event, the next visibility
|
||||||
|
// change or a pull-to-refresh retries; if this was a 429 we are already over
|
||||||
|
// budget and retrying immediately is the worst possible response.
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshFeedInPlace() {
|
/**
|
||||||
try {
|
* Merge the server's current view of the LOADED WINDOW into the in-memory list.
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
*
|
||||||
const params = new URLSearchParams();
|
* Reconciling only page 1 (what this used to do) meant like/comment counts on items
|
||||||
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
* 21..N moved solely via live `like-update` / `new-comment` — so for a guest who had
|
||||||
params.set('limit', '20');
|
* scrolled through a few hundred photos, everything that happened while their phone
|
||||||
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
* was asleep or their stream was down was lost permanently. Paging at the server's
|
||||||
const byId = new Map(res.uploads.map((u) => [u.id, u]));
|
* 100 cap and stopping after `RECONCILE_MAX_PAGES` keeps that at 1–3 requests instead
|
||||||
|
* of one per 20 items, which at a party's event rate would be its own little DDoS.
|
||||||
|
*
|
||||||
|
* Throws: callers decide whether the failure is worth showing (the pill's tap is, a
|
||||||
|
* background event is not).
|
||||||
|
*/
|
||||||
|
async function refreshFeedInPlace(): Promise<void> {
|
||||||
|
const base = filterParams();
|
||||||
|
base.set('limit', String(RECONCILE_PAGE));
|
||||||
const known = new Set(uploads.map((u) => u.id));
|
const known = new Set(uploads.map((u) => u.id));
|
||||||
|
// How much of the loaded window we set out to re-read for fresh counts.
|
||||||
|
const windowPages = Math.min(
|
||||||
|
RECONCILE_MAX_PAGES,
|
||||||
|
Math.max(1, Math.ceil(uploads.length / RECONCILE_PAGE))
|
||||||
|
);
|
||||||
|
const fetched: FeedUpload[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
for (let page = 0; page < RECONCILE_MAX_PAGES; page++) {
|
||||||
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
||||||
|
const params = new URLSearchParams(base);
|
||||||
|
if (cursor) params.set('cursor', cursor);
|
||||||
|
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
||||||
|
fetched.push(...res.uploads);
|
||||||
|
cursor = res.next_cursor;
|
||||||
|
if (!cursor) break;
|
||||||
|
// Nothing fetched so far overlaps what we hold, so the head we are prepending is
|
||||||
|
// not yet CONTIGUOUS with the old list — there are still unseen uploads in between.
|
||||||
|
// Keep paging until the two reconverge, or the merge would silently leave a hole
|
||||||
|
// in the middle of the feed (the truncated-delta pill is exactly this case: more
|
||||||
|
// than the backend's 200-row delta cap arrived while the guest was away).
|
||||||
|
const bridged = known.size === 0 || fetched.some((u) => known.has(u.id));
|
||||||
|
if (page + 1 >= windowPages && bridged) break;
|
||||||
|
}
|
||||||
|
const byId = new Map(fetched.map((u) => [u.id, u]));
|
||||||
uploads = uploads.map((u) => byId.get(u.id) ?? u);
|
uploads = uploads.map((u) => byId.get(u.id) ?? u);
|
||||||
const fresh = res.uploads.filter((u) => !known.has(u.id));
|
const fresh = fetched.filter((u) => !known.has(u.id));
|
||||||
if (fresh.length) uploads = [...fresh, ...uploads];
|
if (fresh.length) uploads = [...fresh, ...uploads];
|
||||||
} catch {
|
}
|
||||||
// Background refresh — stay quiet, the next event or pull-to-refresh retries.
|
|
||||||
|
/**
|
||||||
|
* The "Neue Beiträge" pill's action. MERGES page 1 into the head of what is already
|
||||||
|
* loaded instead of replacing the array with it.
|
||||||
|
*
|
||||||
|
* Replacing collapsed the virtualizer's total size from however many rows were loaded
|
||||||
|
* (400+ after an evening of scrolling) down to 20, so the browser dropped the reader at
|
||||||
|
* an arbitrary offset in a feed that had just shrunk under them — the exact yank the
|
||||||
|
* pill exists to prevent (see the `feedStale` comment at the top of this file).
|
||||||
|
* `nextCursor` is deliberately left alone for the same reason: the tail below the fold
|
||||||
|
* is still loaded and still paginating from where it was.
|
||||||
|
*/
|
||||||
|
async function refreshStale() {
|
||||||
|
try {
|
||||||
|
await refreshFeedInPlace();
|
||||||
|
feedStale = false;
|
||||||
|
} catch (e) {
|
||||||
|
// Keep the pill up so the tap can be retried. Clearing it before the request (as
|
||||||
|
// this used to) left a guest whose tap failed with no signal AND no control.
|
||||||
|
toastError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFeed(refresh = false) {
|
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 {
|
try {
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
const params = filterParams();
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (!refresh && nextCursor) params.set('cursor', nextCursor);
|
if (!refresh && nextCursor) params.set('cursor', nextCursor);
|
||||||
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
|
||||||
params.set('limit', '20');
|
params.set('limit', '20');
|
||||||
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
||||||
uploads = res.uploads;
|
uploads = res.uploads;
|
||||||
nextCursor = res.next_cursor;
|
nextCursor = res.next_cursor;
|
||||||
|
loadError = false;
|
||||||
|
// A full refresh (pull-to-refresh, filter change) has resynced page 1, so the
|
||||||
|
// "new posts" pill is no longer relevant. Cleared only on SUCCESS: clearing it up
|
||||||
|
// front meant a failed pull-to-refresh silently ate the one affordance the guest
|
||||||
|
// had for getting the new photos.
|
||||||
|
if (refresh) feedStale = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Initial / user-triggered refresh is worth surfacing — background SSE refetches are noisier and silenced below.
|
// Every path through here is user-triggered (first load, pull-to-refresh, filter
|
||||||
if (!refresh) toastError(e);
|
// change) — silencing the refresh ones, as this used to, made pull-to-refresh fail
|
||||||
|
// completely invisibly.
|
||||||
|
toastError(e);
|
||||||
|
// Only the case where we have nothing left on screen earns the full error view;
|
||||||
|
// a failed refresh over an already-populated feed keeps the feed.
|
||||||
|
if (uploads.length === 0) loadError = true;
|
||||||
} finally {
|
} finally {
|
||||||
initialLoading = false;
|
initialLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Erneut laden" from the error state — a clean re-run of everything onMount loads. */
|
||||||
|
async function retryInitialLoad() {
|
||||||
|
loadError = false;
|
||||||
|
initialLoading = true;
|
||||||
|
nextCursor = null;
|
||||||
|
await Promise.all([loadFeed(true), loadHashtags(), loadUploaders()]);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (!nextCursor || loadingMore) return;
|
if (!nextCursor || loadingMore) return;
|
||||||
loadingMore = true;
|
loadingMore = true;
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
const params = filterParams();
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set('cursor', nextCursor);
|
params.set('cursor', nextCursor);
|
||||||
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
|
||||||
params.set('limit', '20');
|
params.set('limit', '20');
|
||||||
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
||||||
uploads = [...uploads, ...res.uploads];
|
uploads = [...uploads, ...res.uploads];
|
||||||
@@ -473,6 +628,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadUploaders() {
|
||||||
|
try {
|
||||||
|
uploaderNames = await api.get<string[]>('/uploaders');
|
||||||
|
} catch {
|
||||||
|
// Same as the hashtag index: the picker degrades, the feed keeps working.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function pullRefresh() {
|
async function pullRefresh() {
|
||||||
if (refreshing) return;
|
if (refreshing) return;
|
||||||
refreshing = true;
|
refreshing = true;
|
||||||
@@ -480,16 +643,69 @@
|
|||||||
vibrate(10);
|
vibrate(10);
|
||||||
try {
|
try {
|
||||||
nextCursor = null;
|
nextCursor = null;
|
||||||
await Promise.all([loadFeed(true), loadHashtags()]);
|
await Promise.all([loadFeed(true), loadHashtags(), loadUploaders()]);
|
||||||
} finally {
|
} finally {
|
||||||
refreshing = false;
|
refreshing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Filter state ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// BOTH views filter server-side now; the two states below are just the two UIs for it.
|
||||||
|
// `selectedHashtag` is the list's single chip, `activeFilters` the grid's chip row, and
|
||||||
|
// `filterParams()` is the one place either is turned into a request.
|
||||||
|
//
|
||||||
|
// Previously the list filtered server-side while the grid filtered the loaded array
|
||||||
|
// client-side, and the two never synced — so a tag picked in the list kept filtering the
|
||||||
|
// grid (the fetch still carried `?hashtag=`) while the grid's chip row rendered the empty
|
||||||
|
// `activeFilters`: active, invisible, unclearable. The grid also matched a caption
|
||||||
|
// substring rather than the tag, so `#tanz` matched `#tanzflaeche` in one view and not the
|
||||||
|
// other, and it could only ever see the pages already loaded.
|
||||||
|
//
|
||||||
|
// The helpers below keep the two chip UIs in sync when switching views.
|
||||||
|
|
||||||
|
/** The tag currently shown as a grid chip, if any. Grid supports several; list has one. */
|
||||||
|
function firstTagFilter(): string | null {
|
||||||
|
return activeFilters.find((f) => f.type === 'tag')?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active filter as query params — the SINGLE place that translates UI state into a
|
||||||
|
* request, used by every fetch path (initial load, pagination, pull-to-refresh, and the
|
||||||
|
* SSE-driven in-place refresh).
|
||||||
|
*
|
||||||
|
* Those four used to build their own params, and each only knew about `selectedHashtag` —
|
||||||
|
* so the grid's chips were never sent at all and were applied client-side over whatever
|
||||||
|
* pages happened to be loaded.
|
||||||
|
*/
|
||||||
|
function filterParams(): URLSearchParams {
|
||||||
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (viewMode === 'list') {
|
||||||
|
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
const tags = activeFilters.filter((f) => f.type === 'tag').map((f) => f.value);
|
||||||
|
if (tags.length) params.set('hashtags', tags.join(','));
|
||||||
|
const user = activeFilters.find((f) => f.type === 'user');
|
||||||
|
if (user) params.set('uploader', user.value);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-run page 1 under the current filters. Any filter change resets pagination. */
|
||||||
|
function reloadForFilters() {
|
||||||
|
nextCursor = null;
|
||||||
|
loadFeed(true);
|
||||||
|
}
|
||||||
|
|
||||||
function selectHashtag(tag: string | null) {
|
function selectHashtag(tag: string | null) {
|
||||||
selectedHashtag = tag;
|
selectedHashtag = tag;
|
||||||
nextCursor = null;
|
// Mirror into the grid chips so switching views SHOWS the active filter. Tag chips are
|
||||||
loadFeed();
|
// replaced rather than appended: the list's model is one tag. User chips are grid-only,
|
||||||
|
// so they survive untouched.
|
||||||
|
const users = activeFilters.filter((f) => f.type === 'user');
|
||||||
|
activeFilters = tag ? [{ type: 'tag', value: tag }, ...users] : users;
|
||||||
|
reloadForFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLike(id: string) {
|
async function handleLike(id: string) {
|
||||||
@@ -527,28 +743,85 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectSuggestion(item: Filter) {
|
function selectSuggestion(item: Filter) {
|
||||||
if (!activeFilters.some((f) => f.type === item.type && f.value === item.value)) {
|
if (item.type === 'user') {
|
||||||
|
// Exactly ONE uploader at a time. The server takes a single `uploader` param
|
||||||
|
// rather than a list because display names may contain a comma, and a CSV would
|
||||||
|
// silently split such a name into two filters that match nobody. Tags are safe to
|
||||||
|
// CSV — the backend restricts them to ASCII alphanumerics and `_`.
|
||||||
|
activeFilters = [...activeFilters.filter((f) => f.type !== 'user'), item];
|
||||||
|
} else if (!activeFilters.some((f) => f.type === item.type && f.value === item.value)) {
|
||||||
activeFilters = [...activeFilters, item];
|
activeFilters = [...activeFilters, item];
|
||||||
}
|
}
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
showAutocomplete = false;
|
showAutocomplete = false;
|
||||||
|
reloadForFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFilter(item: Filter) {
|
function removeFilter(item: Filter) {
|
||||||
activeFilters = activeFilters.filter((f) => !(f.type === item.type && f.value === item.value));
|
activeFilters = activeFilters.filter((f) => !(f.type === item.type && f.value === item.value));
|
||||||
|
reloadForFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `selectedHashtag` is cleared too, not just the grid chips: the two are mirrors of one
|
||||||
|
// server-side filter (see `selectHashtag`), so dropping only `activeFilters` left the
|
||||||
|
// list's tag armed and invisible — switching back to the list silently re-applied it.
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
activeFilters = [];
|
activeFilters = [];
|
||||||
|
selectedHashtag = null;
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
|
reloadForFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the empty feed we are looking at is "no matches" rather than "no photos yet".
|
||||||
|
* Both chip UIs count, because the list and the grid express the same server-side
|
||||||
|
* filter differently.
|
||||||
|
*/
|
||||||
|
const hasActiveFilters = $derived(
|
||||||
|
viewMode === 'list' ? selectedHashtag !== null : activeFilters.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Lightbox stepping ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Prev/next walk THE SAME array the feed renders — already server-filtered and
|
||||||
|
// server-ordered — so "next" in the lightbox is the photo the guest would have
|
||||||
|
// scrolled to. It stops at the end of what is LOADED rather than paging: infinite
|
||||||
|
// scroll owns pagination, and having the modal extend the list would grow the
|
||||||
|
// virtualizer behind it while it is the thing holding focus.
|
||||||
|
const lightboxIndex = $derived(
|
||||||
|
selectedUpload ? uploads.findIndex((u) => u.id === selectedUpload!.id) : -1
|
||||||
|
);
|
||||||
|
|
||||||
|
function stepLightbox(delta: -1 | 1) {
|
||||||
|
if (lightboxIndex < 0) return;
|
||||||
|
const next = uploads[lightboxIndex + delta];
|
||||||
|
if (next) selectedUpload = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchView(mode: 'list' | 'grid') {
|
function switchView(mode: 'list' | 'grid') {
|
||||||
viewMode = mode;
|
const before = filterParams().toString();
|
||||||
if (mode === 'list') {
|
if (mode === 'grid') {
|
||||||
|
// Carry the list's tag into the grid's chip row so it is visible — and therefore
|
||||||
|
// removable — there.
|
||||||
|
if (
|
||||||
|
selectedHashtag &&
|
||||||
|
!activeFilters.some((f) => f.type === 'tag' && f.value === selectedHashtag)
|
||||||
|
) {
|
||||||
|
activeFilters = [{ type: 'tag', value: selectedHashtag }, ...activeFilters];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
showAutocomplete = false;
|
showAutocomplete = false;
|
||||||
|
// The list expresses exactly one tag, so carry the first chip across. A second tag
|
||||||
|
// or an uploader chip cannot be represented here; they stay on `activeFilters` and
|
||||||
|
// come back when the user returns to the grid.
|
||||||
|
selectedHashtag = firstTagFilter();
|
||||||
}
|
}
|
||||||
|
viewMode = mode;
|
||||||
|
// Both views send their filters to the server, but they express them differently
|
||||||
|
// (`hashtag` vs `hashtags`+`uploader`), so refetch only when the effective query
|
||||||
|
// actually changed — switching views with no filter must not reset pagination.
|
||||||
|
if (filterParams().toString() !== before) reloadForFilters();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -604,10 +877,7 @@
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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={() => {
|
onclick={() => void refreshStale()}
|
||||||
feedStale = false;
|
|
||||||
void loadFeed(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Neue Beiträge – tippen zum Aktualisieren
|
Neue Beiträge – tippen zum Aktualisieren
|
||||||
</button>
|
</button>
|
||||||
@@ -781,7 +1051,7 @@
|
|||||||
element self-destructing is harmless, and press-then-slide-off aborts like a button
|
element self-destructing is harmless, and press-then-slide-off aborts like a button
|
||||||
should. -->
|
should. -->
|
||||||
<div
|
<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"
|
class="absolute left-0 right-0 top-full z-50 mt-1 max-h-72 overflow-y-auto overscroll-contain rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
||||||
onmousedown={(e) => e.preventDefault()}
|
onmousedown={(e) => e.preventDefault()}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@@ -918,10 +1188,51 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if loadError}
|
||||||
|
<!-- Must sit AHEAD of the empty state: a failed load also leaves `uploads` empty, and
|
||||||
|
"Noch keine Fotos" is then an outright lie — the one thing a guest on a congested
|
||||||
|
venue WiFi must not be told, since there is no operator to correct it. -->
|
||||||
|
<div class="py-20 text-center" data-testid="feed-error">
|
||||||
|
<svg
|
||||||
|
class="mx-auto mb-3 h-12 w-12 text-gray-300 dark:text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Galerie konnte nicht geladen werden.
|
||||||
|
</p>
|
||||||
|
<p class="mx-auto mt-1 max-w-xs text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Das WLAN ist gerade voll. Versuch es gleich noch einmal.
|
||||||
|
</p>
|
||||||
|
<button onclick={() => void retryInitialLoad()} class="btn btn-primary btn-sm mt-4">
|
||||||
|
Erneut laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if uploads.length === 0 && hasActiveFilters}
|
||||||
|
<!-- "No matches" is checked BEFORE "no photos": since filtering moved server-side,
|
||||||
|
an empty response under an active filter is exactly that, and the generic empty
|
||||||
|
state told a guest who had just tapped a hashtag chip to go take a photo. -->
|
||||||
|
<div class="py-16 text-center" data-testid="feed-no-matches">
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
Keine Treffer für die gewählten Filter.
|
||||||
|
</p>
|
||||||
|
<button onclick={clearFilters} class="btn btn-ghost btn-sm mt-2">Filter zurücksetzen</button>
|
||||||
|
</div>
|
||||||
{:else if uploads.length === 0}
|
{:else if uploads.length === 0}
|
||||||
<div class="py-20 text-center">
|
<div class="py-20 text-center">
|
||||||
<p class="text-lg text-gray-400 dark:text-gray-500">Noch keine Fotos.</p>
|
<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>
|
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
Tippe auf den Kamera-Button unten!
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if viewMode === 'list'}
|
{:else if viewMode === 'list'}
|
||||||
<!-- List view: chronological full-width cards (DOM-windowed) -->
|
<!-- List view: chronological full-width cards (DOM-windowed) -->
|
||||||
@@ -937,23 +1248,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Grid view: 3-col, filters applied (DOM-windowed by row) -->
|
<!-- Grid view: 3-col, filters applied server-side (DOM-windowed by row). The
|
||||||
|
"keine Treffer" branch that used to live here was unreachable — `displayUploads`
|
||||||
|
is a plain alias of `uploads` since filtering moved server-side, so its emptiness
|
||||||
|
check was identical to the one two branches above, which always won. It now
|
||||||
|
lives up there, where it can actually be reached from either view. -->
|
||||||
<div class="mx-auto max-w-2xl">
|
<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
|
<VirtualFeed
|
||||||
mode="grid"
|
mode="grid"
|
||||||
uploads={displayUploads}
|
uploads={displayUploads}
|
||||||
@@ -963,7 +1263,6 @@
|
|||||||
onselect={(u) => (selectedUpload = u)}
|
onselect={(u) => (selectedUpload = u)}
|
||||||
oncontextmenu={openContextSheet}
|
oncontextmenu={openContextSheet}
|
||||||
/>
|
/>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -986,6 +1285,10 @@
|
|||||||
upload={selectedUpload}
|
upload={selectedUpload}
|
||||||
onclose={() => (selectedUpload = null)}
|
onclose={() => (selectedUpload = null)}
|
||||||
onlike={handleLike}
|
onlike={handleLike}
|
||||||
|
hasPrev={lightboxIndex > 0}
|
||||||
|
hasNext={lightboxIndex >= 0 && lightboxIndex < uploads.length - 1}
|
||||||
|
onprev={() => stepLightbox(-1)}
|
||||||
|
onnext={() => stepLightbox(1)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user