First audit round: a blocker and eight high-severity findings across the media
access gate, the guest-facing rate limiters, host moderation, and the upload
pipeline — plus the backup commands and the e2e/production divergences that hid
several of them.
Squashed from 7 commits, original messages preserved below.
──────── fix(export): let the keepsake download through X-Frame-Options on iOS
The keepsake download navigates a hidden, same-origin iframe (deliberately:
a top-level navigation to a 404/429 would unload the PWA). Caddy stamped a
site-wide `X-Frame-Options: DENY` that also covered the proxied `/api/*`.
Blink hands a `Content-Disposition: attachment` response to the download
manager at the network layer, so Chromium never noticed. WebKit enforces XFO
on the frame navigation first and aborts the load — so on iOS Safari, the
app's primary platform, tapping Download did nothing at all, silently.
Carve the two export endpoints out to SAMEORIGIN, which still blocks
cross-origin framing. Implemented as two disjoint matchers rather than an
override: Caddy applies the FIRST `header` directive outermost, so it wins on
write and a later, more specific `header` is silently ignored (verified
against the running test stack).
Also close the test gap that let this ship:
- `06-export` ran on chromium-desktop only; add it to `webkit-iphone`, the
only engine that enforces XFO on the download frame.
- No test in the suite ever clicked a download button — every archive
assertion used Node `fetch`, which has no frame and no XFO enforcement.
Add a spec that clicks it and awaits a real `download` event. Verified
falsifiable: with the blanket DENY reinstated it fails and reports the
WebKit refusal as the cause.
- Fix `ExportPage`'s card-scoped locators, which matched nothing: the cards
carry `class="card p-5"` (a Tailwind `@apply` component class), never the
`rounded-xl` the page object looked for. This had left the "shows enabled
download buttons" test red on main.
──────── fix(media): close the percent-escape bypass of the media gate
`/media/%70reviews/{id}.jpg` served a taken-down photo to anyone,
unauthenticated. Verified against the running stack: the literal path 404s,
the escaped one returned 200 with the full image. Same for displays,
thumbnails and originals, and any escaped byte in any position works.
Cause: the block was four `nest_service("/media/previews", 404)` route
matches sitting above a `ServeDir` on `/media`. axum matches on the RAW path
(matchit does no percent-decoding), while `ServeDir` percent-decodes when it
resolves the file. So `%70reviews` missed every blocker, fell through to the
ServeDir, and was decoded back to `previews/` on disk — reaching the bytes
with no soft-delete and no ban-hide check. That defeats a host takedown,
which is the entire point of the gate.
Remove the `/media` route tree outright instead of racing the decoder.
Nothing needs it: every media URL the backend emits is already a gated
`/api/v1/upload/{id}/{original,preview,display,thumbnail}` alias
(handlers::feed), the frontend contains zero `/media/` references, and the
`/media` in config.rs/disk.rs is the filesystem path while `media/` in
export.rs is a path inside the zip. `/media/**` now 404s regardless of
encoding. The route's own comment already said it "serves nothing" — it
wasn't a backstop, it was the vector.
Caddy keeps proxying /media/* deliberately: the app 404s it, and forwarding
means the e2e gating specs exercise the app's refusal exactly as production
would rather than being masked by the SvelteKit 404 page.
Extend the gating spec with the encoded variants — asserting only the literal
spelling is what let this sit undetected.
──────── fix(rate-limit): key the guest-facing limiters per user, not per IP
At a venue every guest is behind one NAT, so an IP-keyed limiter hands the
whole party a single bucket. On a fresh deploy 12 guests arriving together
meant 5 joined and 7 were turned away, with no Retry-After telling them when
to retry. `/feed` (60/min) and `/export` (3 per DAY — the fourth guest to
fetch their keepsake locked out until tomorrow) had the same defect.
`feed_delta` was already keyed per-user and its comment states the exact
rationale ("so one client can't starve others behind a shared NAT"); this
makes its siblings match.
- feed:{ip} -> feed:{user_id} (auth was already in scope)
- export:{ip} -> export:{user_id} (resolved from the download ticket's
session, which was previously looked up and discarded)
- join:{ip}: pre-auth, so there is no user to key on. Split in two — a loose
per-IP ceiling that only bounds raw volume (new `join_ip_rate_per_min`,
default 60, migration 017), plus the real 5/60s anti-spam bucket keyed
per (ip, name), mirroring the existing `recover:{ip}:{name}`.
admin_login / recover / pin_reset_req stay IP-keyed on purpose and are now
commented as such: they guard credential guessing, where a per-user or
per-name key would just hand an attacker a fresh bucket per guess.
Retry-After: the machinery existed but 7 of 8 sites called `check()` and
hard-coded `None`, so a throttled client was told to back off but never for
how long. Delete the bool `check()` wrapper entirely so `check_with_retry`
is the only entry point and the delay cannot be discarded by accident. Also
surface it for the PIN lockout, where the deadline was already known.
Fix the "unknown" fallback while here: every client_ip() caller passed that
literal, so any request without X-Forwarded-For — anything reaching the app
directly rather than through Caddy — shared ONE global bucket. Serve with
connect-info and use the peer address.
Tests: the reseed forces every limiter toggle off before each test, which is
why this whole class was invisible. Add 01-auth/rate-limit-shared-nat, which
enables them and asserts 12 guests share an IP without collision, that one
guest hammering their own name IS still throttled (so the fix re-keys rather
than removes the limit), and that feed/export buckets are per-user. Retarget
the ddos join test at the new per-IP ceiling — it asserted the defect.
Also seed `admin_login_rate_enabled` (read by the handler, seeded by no
migration and no reseed) and register `join_ip_rate_per_min` in the admin
config allowlist. Unrelated pre-existing red test fixed: 01-auth/join
asserted a "Willkommen!" heading the wedding redesign removed.
──────── feat(moderation): let a host remove a guest's photo or comment from the UI
`DELETE /host/upload/{id}` and `DELETE /host/comment/{id}` were complete on the
backend — transactional, SSE-broadcasting, audit-logged — and had zero frontend
callers. The feed context sheet offered "Löschen" only when
`target.user_id === myUserId`, so the only lever a host actually had against an
unwanted photo was banning the uploader.
That is both disproportionate and ineffective. A ban doesn't retract what was
already posted, and it makes things strictly worse for comments: the ban check
runs BEFORE the ownership check on the guest delete route, so banning an abusive
author leaves their comment on screen and permanently undeletable by them. With
no host affordance, nobody could remove it at all.
- feed: hosts/admins get "Beitrag entfernen" on other people's posts, routed to
the host endpoint (the guest route 403s anything the caller doesn't own) with
moderation-specific confirm copy. Own-post "Löschen" is unchanged.
- lightbox: same for comments, via /host/comment/{id}.
- Ban semantics are deliberately untouched (USER_JOURNEYS §10 — banned users keep
read access and cannot write). The deadlock is broken by giving the host a way
in, not by loosening the ban.
Live role (this had to come first). `getRole()` decodes the JWT claim, but the
token is never reissued — the backend slides the session row forward and treats
the DB row as authoritative. The claim is therefore frozen for the token's
lifetime: up to 30 days. A guest promoted at the party saw no Host-Dashboard and
no moderation actions until they signed out and back in, even though
`/me/context` had been returning their real role on every page load and 4 of its
6 call sites dropped the field on the floor.
Add `role-store.ts`: seeded from the claim so there's no flash of the wrong nav,
then corrected by every `/me/context` response. Point the ad-hoc `getRole()`
callers at it (account, upload, host, admin, and the new feed gate). The host and
admin dashboards now derive `myRole` reactively, so a demotion disables their
controls immediately instead of at next login.
Tests: 04-host/moderation-ui drives the real UI — host removes a guest photo and
it's gone from /feed server-side; a plain guest is offered nothing on someone
else's post (the mirror that keeps the first test honest); a promoted guest gains
the dashboard on reload while their token still carries `role: guest`; and a host
removes the comment of an already-banned guest, asserting first that the author's
own delete 403s so the deadlock is real.
──────── fix(upload): stop destroying originals, apply EXIF orientation, surface rejections
Three defects in the same pipeline, each of which loses a photo or misrepresents
one.
1. A transient error destroyed the guest's only copy.
`process`'s error arm unconditionally `remove_file`d the original. Every failure
routed there: `create_dir_all`, both derivative `save_with_format` calls (disk
full is the canonical case, and it arrives exactly when many guests upload at
once), a panic inside the image codec, or a momentary DB-pool exhaustion. The
row is only SOFT-deleted, so the bytes were the sole unrecoverable part — and
they were the part we deleted. The author already knew this was wrong next door:
`backfill_missing_display` says it "must NEVER soft-delete an upload that already
has a working preview".
Retry up to 3 times with backoff (re-checking the e2e generation guard after each
sleep), and on final failure keep the refund + soft-delete but leave the original
on disk, logging its path. A failed upload is now recoverable instead of gone.
2. Every portrait photo was stored sideways.
Phones don't rotate sensor data — they record the camera orientation in EXIF and
store the pixels as shot. `decode()` returns those raw pixels and the JPEG
re-encode writes no EXIF, so the 800px preview, the 2048px diashow display and
the keepsake were all rotated 90°, while "Original anzeigen" rendered upright
because the original keeps its tag. That asymmetry is why it reads as a viewer
bug. There was no EXIF handling anywhere in the repo and no exif crate.
Read the tag via `into_decoder()` (which carries the decode Limits through, so
the decompression-bomb cap is untouched) and apply it. Missing/malformed tags
fall back to NoTransforms — most images have none.
Existing derivatives are already baked wrong, so migration 018 adds
`derivatives_rev` and `backfill_missing_display` becomes
`backfill_stale_derivatives`: it now also picks up anything below the current rev
and regenerates it once from the original, which still carries its EXIF. Videos
are marked current in the migration — ffmpeg already honours the rotation matrix.
Bump DERIVATIVES_REV for any future change that invalidates derivatives.
3. A rejected upload vanished without a word.
`UploadQueue.svelte` — 162 lines holding the ONLY renderer of an item's error
text, the only "Erneut" retry button and the only rate-limit countdown — was
never imported anywhere, so `retryItem`, `removeItem` and `clearCompleted` were
unreachable at runtime. On a terminal rejection the store purged the blob and
wrote a clear German reason into `entry.error` "so the UI shows a clear reason".
There was no such UI. And `uploadBadgeCount` counted only pending/uploading, so
the badge decremented exactly as if the upload had succeeded.
Mount the queue on /upload, toast the reason immediately (the flow sends the user
to /feed straight after staging, so the list alone would still miss them), and
count blocked/error in the badge so a failure can't read as success.
Tests: 02-upload/exif-orientation uploads a 40x20 fixture tagged Orientation=6
and asserts both derivatives come back PORTRAIT, with a sanity check that the
source really is stored landscape. 02-upload/rejection-visible bans the uploader
between staging and sending, then asserts the toast, the queue row with the
server's reason, and that the item is still counted.
Note: 02-upload/quota's 4 failures are pre-existing and unrelated — see the next
commit.
──────── docs(backup): make the backup commands work; fix the e2e/prod divergences
Backup. Both documented commands failed on the shipped stack, and the sentence
explaining them was wrong too:
- `pg_dump $DATABASE_URL` — `DATABASE_URL` is only ever in the compose
environment, never an operator's shell, and it points at `db:5432`, which is
compose-internal DNS. The app image has no postgres client either.
- `> /media/backups/…` — `/media` is a named volume mounted inside the app
container, not a host path, and nothing ever creates a `backups` subdirectory.
- `rsync /opt/eventsnap/media/` — that path does not exist anywhere.
- "a single path to back up" — false, and dangerously so: exports were moved to
their own `exports_data` volume precisely so a keepsake (which contains every
photo in the event) can't be served off the media tree. Backing up only
`media_data` silently loses every generated keepsake.
Rewritten as three commands — db via `docker compose exec -T db pg_dump`, and one
`docker run … tar` per volume — all verified against the running stack. The
volume mounts use `/src`, not `/media`: I hit the footgun while testing this.
Docker pre-populates an EMPTY volume from the image's own directory and chowns it
to match, so `-v media_data:/media alpine` tars alpine's cdrom/floppy/usb, writes
them into the volume, and leaves it root-owned so the non-root app can no longer
write. Mounting where the image has nothing avoids all of it. Documented inline
so the next person doesn't rediscover it.
Also correct the architecture notes: `/media/*` no longer routes to the backend
(that static tree was removed as a gating bypass), and `exports_data` was missing
from the volume list — the one volume an operator most needs to know about.
e2e stack: add the `EXPORT_PATH` + `/exports` volume it was missing. The file
says "mirrors production layout"; without these, exports landed on the container's
writable layer at the default path, so export-leak and export-video wrote real
archives into ephemeral storage and the "exports live outside media" invariant
was never actually exercised.
Pre-existing red test, unrelated to the audit: all four 02-upload/quota tests
have been failing since 4464147 "stop /me/quota leaking raw disk to guests"
(2026-07-19), which post-dates the spec's last edit. `setLimitTo` calibrated
`quota_tolerance` from `free_disk_bytes` read through the GUEST's token — a field
that commit deliberately zeroes for non-staff. Dividing by it yields a NaN
tolerance, so every test in the block died in the helper. Read the calibration
inputs through a staff token and keep reading the ceiling back through the guest,
whose limit is the thing under test.
──────── test(e2e): document why WebKit can't run the client-queue upload tests
Three 02-upload tests have been failing on `webkit-iphone` on main — `02-upload`
was already in that project's testMatch, so this is pre-existing red, not
something the audit work introduced.
Root cause is the harness, not the app. Playwright's Linux WebKit build cannot
store Blobs in IndexedDB at all: `put()` fails with "UnknownError: Error
preparing Blob/File data to be stored in object store". Confirmed it is not
about how Playwright delivers files — a Blob constructed in-page with
`new Blob([bytes])` fails identically, while Chromium stores both that and a
`setInputFiles` File without complaint.
That breaks every test driving the composer (FAB → UploadSheet → /upload →
submit), because `handleSubmit` awaits `addToQueue`, which persists the file
before it can navigate. The symptom is a submit button stuck on "Wird
hochgeladen…" and a timeout waiting for /feed — which reads like an app hang and
cost real time to run down.
Skip those four (the three above plus the new rejection-visible) on WebKit only,
behind a named helper carrying the full explanation, so the next person gets the
answer instead of the investigation. Deliberately narrow: WebKit still runs every
API-driven upload test, all of 01-auth, 03-feed and 06-export — including the
keepsake download, which only WebKit can meaningfully verify. Chromium continues
to run all 14.
Worth being explicit, since these tests exist to protect iOS: real Safari
supports Blobs in IndexedDB, so this is NOT evidence that the offline upload
queue is broken on the platform. It does mean that guarantee is currently
unverifiable in CI and rests on Chromium coverage plus manual device testing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1004 lines
37 KiB
Svelte
1004 lines
37 KiB
Svelte
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
import { getToken, getUserId } from '$lib/auth';
|
||
import { isStaff } from '$lib/role-store';
|
||
import { api } from '$lib/api';
|
||
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
|
||
import { onMount, onDestroy } from 'svelte';
|
||
import VirtualFeed from '$lib/components/VirtualFeed.svelte';
|
||
import HashtagChips from '$lib/components/HashtagChips.svelte';
|
||
import LightboxModal from '$lib/components/LightboxModal.svelte';
|
||
import OnboardingGuide from '$lib/components/OnboardingGuide.svelte';
|
||
import ContextSheet, { type ContextAction } from '$lib/components/ContextSheet.svelte';
|
||
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
|
||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||
import { refreshQuota } from '$lib/quota-store';
|
||
import { exportStatus } from '$lib/export-status-store';
|
||
import { toast, toastError } from '$lib/toast-store';
|
||
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
|
||
import { vibrate } from '$lib/haptics';
|
||
import { filterUploads } from '$lib/feed-filter';
|
||
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
|
||
|
||
let uploads = $state<FeedUpload[]>([]);
|
||
let hashtags = $state<HashtagCount[]>([]);
|
||
let selectedHashtag = $state<string | null>(null);
|
||
let nextCursor = $state<string | null>(null);
|
||
let loadingMore = $state(false);
|
||
let initialLoading = $state(true);
|
||
let refreshing = $state(false);
|
||
let pullProgress = $state(0); // 0–1+ during the drag, 0 when idle
|
||
let selectedUpload = $state<FeedUpload | null>(null);
|
||
// Set when a truncated feed-delta means we missed too much to merge — shows a
|
||
// tap-to-refresh pill instead of yanking the user's scroll to page 1.
|
||
let feedStale = $state(false);
|
||
let sentinel: HTMLDivElement;
|
||
let feedObserver: IntersectionObserver | null = null;
|
||
let inPlaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||
// `asHost` picks the endpoint AND the copy: removing someone else's photo is a
|
||
// moderation action, not "delete my post", and it hits the host route.
|
||
let pendingDelete = $state<{ id: string; asHost: boolean } | null>(null);
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// onMount A — DOM side-effects only (overscroll lock). Synchronous, returns
|
||
// its own cleanup. Kept separate from the data-loading onMount below so its
|
||
// cleanup can't be accidentally clobbered when someone edits the async one.
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
onMount(() => {
|
||
if (typeof document === 'undefined') return;
|
||
const prev = document.documentElement.style.overscrollBehaviorY;
|
||
document.documentElement.style.overscrollBehaviorY = 'contain';
|
||
return () => {
|
||
document.documentElement.style.overscrollBehaviorY = prev;
|
||
};
|
||
});
|
||
|
||
// View mode
|
||
let viewMode = $state<'list' | 'grid'>('list');
|
||
|
||
// Grid search / filter state
|
||
let searchQuery = $state('');
|
||
let showAutocomplete = $state(false);
|
||
|
||
interface Filter {
|
||
type: 'tag' | 'user';
|
||
value: string;
|
||
}
|
||
let activeFilters = $state<Filter[]>([]);
|
||
|
||
let unsubscribers: (() => void)[] = [];
|
||
|
||
// Long-press / context-sheet state for post actions
|
||
let contextTarget = $state<FeedUpload | null>(null);
|
||
const myUserId = getUserId();
|
||
const contextActions = $derived<ContextAction[]>(buildContextActions(contextTarget));
|
||
|
||
function buildContextActions(target: FeedUpload | null): ContextAction[] {
|
||
if (!target) return [];
|
||
const actions: ContextAction[] = [
|
||
{
|
||
label: 'Original anzeigen',
|
||
icon: '⤓',
|
||
onClick: () => {
|
||
window.open(`/api/v1/upload/${target.id}/original`, '_blank');
|
||
}
|
||
}
|
||
];
|
||
if (target.user_id === myUserId) {
|
||
actions.unshift({
|
||
label: 'Löschen',
|
||
icon: '🗑',
|
||
tone: 'danger',
|
||
onClick: () => {
|
||
pendingDelete = { id: target.id, asHost: false };
|
||
}
|
||
});
|
||
} else if ($isStaff) {
|
||
// Moderation. Without this the only lever a host had against an unwanted photo
|
||
// was banning the uploader — which is both disproportionate and ineffective,
|
||
// since a ban does not retract what they already posted.
|
||
actions.unshift({
|
||
label: 'Beitrag entfernen',
|
||
icon: '🚫',
|
||
tone: 'danger',
|
||
onClick: () => {
|
||
pendingDelete = { id: target.id, asHost: true };
|
||
}
|
||
});
|
||
}
|
||
return actions;
|
||
}
|
||
|
||
function openContextSheet(upload: FeedUpload) {
|
||
contextTarget = upload;
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
const pending = pendingDelete;
|
||
if (!pending) return;
|
||
pendingDelete = null;
|
||
try {
|
||
// The guest route rejects anything the caller doesn't own, so a host removing
|
||
// someone else's photo must go through the host route. That one also emits the
|
||
// `upload-deleted` SSE and writes an audit-log entry.
|
||
await api.delete(pending.asHost ? `/host/upload/${pending.id}` : `/upload/${pending.id}`);
|
||
uploads = uploads.filter((u) => u.id !== pending.id);
|
||
if (selectedUpload?.id === pending.id) selectedUpload = null;
|
||
// Only our own delete frees our quota; a host removal refunds the uploader.
|
||
if (!pending.asHost) void refreshQuota();
|
||
} catch (e) {
|
||
toastError(e);
|
||
}
|
||
}
|
||
|
||
// ── Autocomplete derived from loaded uploads (no extra API calls) ────────
|
||
let allTags = $derived.by(() => {
|
||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local throwaway counter inside a $derived.by; never stored in $state, so no reactivity is involved.
|
||
const freq = new Map<string, number>();
|
||
for (const u of uploads) {
|
||
for (const m of (u.caption ?? '').matchAll(/#(\w+)/g)) {
|
||
const t = m[1].toLowerCase();
|
||
freq.set(t, (freq.get(t) ?? 0) + 1);
|
||
}
|
||
}
|
||
return [...freq.entries()].sort((a, b) => b[1] - a[1]).map(([t]) => t);
|
||
});
|
||
|
||
let allUploaders = $derived([...new Set(uploads.map((u) => u.uploader_name))].sort());
|
||
|
||
// The suggestion SOURCE is frozen for as long as the dropdown is open.
|
||
//
|
||
// `allTags`/`allUploaders` derive from `uploads`, which mutates under us constantly: every
|
||
// `upload-processed` / `new-upload` SSE triggers `refreshFeedInPlace`, which replaces the array.
|
||
// `allTags` is ordered by FREQUENCY, so a photo landing mid-interaction can REORDER the open
|
||
// dropdown — the user presses "#wedding" and the list reshuffles under their finger. At a party,
|
||
// where photos stream in the whole time, that is a live mis-tap hazard, not a theoretical one.
|
||
// (It also detaches the button mid-press: the handler is `onmousedown`, so a re-render between
|
||
// mousedown and mouseup destroys the element being pressed.)
|
||
//
|
||
// Freezing on focus keeps the list the user is looking at identical to the list they act on.
|
||
// Typing still filters — it just filters a stable source. New photos appear in the suggestions
|
||
// the next time the dropdown is opened, which is soon enough for a filter picker.
|
||
let frozenTags = $state<string[]>([]);
|
||
let frozenUploaders = $state<string[]>([]);
|
||
|
||
// Re-snapshot only when actually opening. Called on focus AND on click/input, because after a
|
||
// selection the input keeps focus (the dropdown suppresses the blur) — so `onfocus` will not
|
||
// fire again, and without these the picker could never be reopened without clicking away first.
|
||
// Guarding on `showAutocomplete` keeps typing from re-freezing on every keystroke, which would
|
||
// let the list churn again mid-interaction and undo the point of freezing it.
|
||
function openAutocomplete() {
|
||
if (!showAutocomplete) {
|
||
frozenTags = allTags;
|
||
frozenUploaders = allUploaders;
|
||
}
|
||
showAutocomplete = true;
|
||
}
|
||
|
||
let suggestions = $derived.by((): Filter[] => {
|
||
const q = searchQuery.trim();
|
||
if (!q) {
|
||
// Show top suggestions on focus
|
||
if (!showAutocomplete) return [];
|
||
return [
|
||
...frozenUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })),
|
||
...frozenTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t }))
|
||
];
|
||
}
|
||
if (q.startsWith('#')) {
|
||
const prefix = q.slice(1).toLowerCase();
|
||
return frozenTags
|
||
.filter((t) => t.startsWith(prefix))
|
||
.slice(0, 8)
|
||
.map((t) => ({ type: 'tag' as const, value: t }));
|
||
}
|
||
const lower = q.toLowerCase();
|
||
return [
|
||
...frozenUploaders
|
||
.filter((u) => u.toLowerCase().includes(lower))
|
||
.slice(0, 4)
|
||
.map((u) => ({ type: 'user' as const, value: u })),
|
||
...frozenTags
|
||
.filter((t) => t.includes(lower))
|
||
.slice(0, 4)
|
||
.map((t) => ({ type: 'tag' as const, value: t }))
|
||
];
|
||
});
|
||
|
||
// ── Filtered uploads for grid view ───────────────────────────────────────
|
||
let displayUploads = $derived.by(() => {
|
||
if (viewMode === 'list' || activeFilters.length === 0) return uploads;
|
||
return filterUploads(uploads, activeFilters);
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// onMount B — auth gate, recovery toast, data load, SSE subscriptions,
|
||
// infinite-scroll observer. Cleanup of the SSE handlers lives in onDestroy
|
||
// below (not in a returned cleanup) because this callback is async.
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
onMount(async () => {
|
||
if (!getToken()) {
|
||
goto('/join');
|
||
return;
|
||
}
|
||
|
||
// Surface the welcome-back toast set by /recover. Lives here (not on /account)
|
||
// because /feed is the first hydrated route after a successful PIN recovery —
|
||
// the toast should land in the same beat as the "you're in" feeling.
|
||
if (typeof sessionStorage !== 'undefined') {
|
||
const welcome = sessionStorage.getItem('eventsnap_just_recovered');
|
||
if (welcome) {
|
||
sessionStorage.removeItem('eventsnap_just_recovered');
|
||
toast(`Willkommen zurück, ${welcome}!`, 'success');
|
||
}
|
||
}
|
||
|
||
await Promise.all([loadFeed(), loadHashtags()]);
|
||
connectSse();
|
||
|
||
unsubscribers.push(
|
||
onSseEvent('new-upload', (data) => {
|
||
try {
|
||
const upload: FeedUpload = JSON.parse(data);
|
||
// GRID view must NOT prepend live. Its rows are POSITIONAL windows
|
||
// (`uploads.slice(i * COLS, …)` in VirtualFeed), so inserting at the head shifts
|
||
// every tile by one slot: each row's keyed `{#each}` then sees a different set of
|
||
// ids and Svelte DESTROYS AND RECREATES the tile nodes. Two consequences at a
|
||
// party, where photos arrive continuously — a tap in flight is swallowed when its
|
||
// node is torn out, and the photo under the user's finger silently becomes a
|
||
// DIFFERENT photo, so they like or open one they never chose.
|
||
//
|
||
// The "neue Beiträge" pill already exists for exactly this: buffer, and let the
|
||
// user pull the new photos in when they are not mid-tap. List view is keyed by id
|
||
// at the top level and anchored, so its nodes survive a prepend — it stays live.
|
||
if (viewMode === 'grid') {
|
||
feedStale = true;
|
||
return;
|
||
}
|
||
uploads = [upload, ...uploads];
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}),
|
||
// A processed upload gains preview/thumbnail URLs. Coalesce bursts (bulk
|
||
// uploads fire one per file) into a single in-place merge so the feed
|
||
// neither hammers the server nor collapses to page 1 / loses scroll.
|
||
onSseEvent('upload-processed', () => scheduleInPlaceRefresh()),
|
||
onSseEvent('upload-deleted', (data) => {
|
||
try {
|
||
const payload = JSON.parse(data) as { upload_id: string };
|
||
uploads = uploads.filter((u) => u.id !== payload.upload_id);
|
||
if (selectedUpload?.id === payload.upload_id) selectedUpload = null;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}),
|
||
// A background transcode failed: the backend already cleaned up (refunded
|
||
// quota, removed the row) and an upload-deleted evicts the card. Only the
|
||
// uploader gets a toast — registered before upload-deleted so the card is
|
||
// still present to check ownership.
|
||
onSseEvent('upload-error', (data) => {
|
||
try {
|
||
const { upload_id } = JSON.parse(data) as { upload_id: string };
|
||
const mine = uploads.find((u) => u.id === upload_id && u.user_id === myUserId);
|
||
if (mine) {
|
||
toast('Ein Upload konnte nicht verarbeitet werden.', 'error');
|
||
void refreshQuota();
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}),
|
||
// A banned user's uploads were hidden — drop all their cards live.
|
||
onSseEvent('user-hidden', (data) => {
|
||
try {
|
||
const { user_id } = JSON.parse(data) as { user_id: string };
|
||
uploads = uploads.filter((u) => u.user_id !== user_id);
|
||
if (selectedUpload && selectedUpload.user_id === user_id) selectedUpload = null;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}),
|
||
// Patch the single affected card in place from the SSE payload instead of
|
||
// refetching page 1 — a busy event fires these constantly and a full reload
|
||
// would yank every scrolled-down user back to the top on each reaction.
|
||
onSseEvent('like-update', (data) => patchCount(data, 'like_count')),
|
||
onSseEvent('new-comment', (data) => patchCount(data, 'comment_count')),
|
||
// Synthetic event from the SSE client after a foreground reconnect — merge
|
||
// any uploads + deletions we missed while the tab was hidden.
|
||
onSseEvent('feed-delta', (data) => {
|
||
try {
|
||
const delta = JSON.parse(data) as DeltaResponse;
|
||
// Evict removed content FIRST and unconditionally — deletions AND ban-hides are
|
||
// explicit, uncapped lists, so they apply even on a truncated delta (the stale-pill
|
||
// refresh below only prunes page 1, so a banned user's cards beyond page 1 would
|
||
// otherwise linger). Replays a `user-hidden`/delete missed while the tab was hidden.
|
||
if (delta.deleted_ids.length) {
|
||
const dead = new Set(delta.deleted_ids);
|
||
uploads = uploads.filter((u) => !dead.has(u.id));
|
||
if (selectedUpload && dead.has(selectedUpload.id)) selectedUpload = null;
|
||
}
|
||
if (delta.hidden_user_ids.length) {
|
||
const hidden = new Set(delta.hidden_user_ids);
|
||
uploads = uploads.filter((u) => !hidden.has(u.user_id));
|
||
if (selectedUpload && hidden.has(selectedUpload.user_id)) selectedUpload = null;
|
||
}
|
||
if (delta.truncated) {
|
||
// Missed more than the backend delta cap while backgrounded — the
|
||
// delta is only the newest slice, so merging would leave a silent gap
|
||
// of older-but-still-new uploads. Resync from page 1 instead.
|
||
// Rather than involuntarily yank the user to page 1, surface a
|
||
// tap-to-refresh pill so they keep scroll until they resync.
|
||
feedStale = true;
|
||
return;
|
||
}
|
||
if (delta.uploads.length) {
|
||
const seen = new Set(uploads.map((u) => u.id));
|
||
const fresh = delta.uploads.filter((u) => !seen.has(u.id));
|
||
if (fresh.length) uploads = [...fresh, ...uploads];
|
||
}
|
||
// A delta reconciles new uploads and deletions, but not like/comment
|
||
// counts that changed on already-visible cards while we were
|
||
// disconnected or lagged (a `resync`). Debounced page-1 refresh merges
|
||
// those fresh counts in place without disturbing scroll.
|
||
scheduleInPlaceRefresh();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
})
|
||
);
|
||
|
||
if (sentinel) {
|
||
feedObserver = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries[0].isIntersecting && nextCursor && !loadingMore) loadMore();
|
||
},
|
||
{ rootMargin: '200px' }
|
||
);
|
||
feedObserver.observe(sentinel);
|
||
}
|
||
});
|
||
|
||
onDestroy(() => {
|
||
disconnectSse();
|
||
for (const unsub of unsubscribers) unsub();
|
||
feedObserver?.disconnect();
|
||
if (inPlaceRefreshTimer) clearTimeout(inPlaceRefreshTimer);
|
||
});
|
||
|
||
// Patch a single upload's like/comment count from an SSE payload without
|
||
// disturbing scroll position or the rest of the loaded feed.
|
||
function patchCount(data: string, field: 'like_count' | 'comment_count') {
|
||
try {
|
||
const payload = JSON.parse(data) as {
|
||
upload_id: string;
|
||
like_count?: number;
|
||
comment_count?: number;
|
||
};
|
||
const value = payload[field];
|
||
if (value === undefined) return;
|
||
uploads = uploads.map((u) => (u.id === payload.upload_id ? { ...u, [field]: value } : u));
|
||
if (selectedUpload?.id === payload.upload_id) {
|
||
selectedUpload = { ...selectedUpload, [field]: value };
|
||
}
|
||
} catch {
|
||
/* ignore malformed payloads */
|
||
}
|
||
}
|
||
|
||
// Debounced page-1 fetch that *merges* (updates existing cards in place, prepends
|
||
// genuinely new ones) rather than replacing the array — preserves scroll and any
|
||
// pages already loaded below the fold.
|
||
function scheduleInPlaceRefresh() {
|
||
if (inPlaceRefreshTimer) return;
|
||
inPlaceRefreshTimer = setTimeout(() => {
|
||
inPlaceRefreshTimer = null;
|
||
void refreshFeedInPlace();
|
||
}, 800);
|
||
}
|
||
|
||
async function refreshFeedInPlace() {
|
||
try {
|
||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
||
const params = new URLSearchParams();
|
||
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
||
params.set('limit', '20');
|
||
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
||
const byId = new Map(res.uploads.map((u) => [u.id, u]));
|
||
const known = new Set(uploads.map((u) => u.id));
|
||
uploads = uploads.map((u) => byId.get(u.id) ?? u);
|
||
const fresh = res.uploads.filter((u) => !known.has(u.id));
|
||
if (fresh.length) uploads = [...fresh, ...uploads];
|
||
} catch {
|
||
// Background refresh — stay quiet, the next event or pull-to-refresh retries.
|
||
}
|
||
}
|
||
|
||
async function loadFeed(refresh = false) {
|
||
// Any full refresh (pill tap, pull-to-refresh, filter change) resyncs page 1,
|
||
// so the "new posts" pill is no longer relevant — clear it here rather than
|
||
// only in the pill's own onclick, or a pull-to-refresh leaves it stranded.
|
||
if (refresh) feedStale = false;
|
||
try {
|
||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
||
const params = new URLSearchParams();
|
||
if (!refresh && nextCursor) params.set('cursor', nextCursor);
|
||
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
||
params.set('limit', '20');
|
||
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
||
uploads = res.uploads;
|
||
nextCursor = res.next_cursor;
|
||
} catch (e) {
|
||
// Initial / user-triggered refresh is worth surfacing — background SSE refetches are noisier and silenced below.
|
||
if (!refresh) toastError(e);
|
||
} finally {
|
||
initialLoading = false;
|
||
}
|
||
}
|
||
|
||
async function loadMore() {
|
||
if (!nextCursor || loadingMore) return;
|
||
loadingMore = true;
|
||
try {
|
||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state.
|
||
const params = new URLSearchParams();
|
||
params.set('cursor', nextCursor);
|
||
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
||
params.set('limit', '20');
|
||
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
||
uploads = [...uploads, ...res.uploads];
|
||
nextCursor = res.next_cursor;
|
||
} catch (e) {
|
||
toastError(e);
|
||
} finally {
|
||
loadingMore = false;
|
||
}
|
||
}
|
||
|
||
async function loadHashtags() {
|
||
try {
|
||
hashtags = await api.get<HashtagCount[]>('/hashtags');
|
||
} catch {
|
||
// Hashtag panel is a discoverability nicety — silent fail is acceptable; the feed still works.
|
||
}
|
||
}
|
||
|
||
async function pullRefresh() {
|
||
if (refreshing) return;
|
||
refreshing = true;
|
||
pullProgress = 0;
|
||
vibrate(10);
|
||
try {
|
||
nextCursor = null;
|
||
await Promise.all([loadFeed(true), loadHashtags()]);
|
||
} finally {
|
||
refreshing = false;
|
||
}
|
||
}
|
||
|
||
function selectHashtag(tag: string | null) {
|
||
selectedHashtag = tag;
|
||
nextCursor = null;
|
||
loadFeed();
|
||
}
|
||
|
||
async function handleLike(id: string) {
|
||
try {
|
||
// Set state from the server's authoritative response rather than blind-inverting
|
||
// local state. On a second device (same recovered user), the `like-update`
|
||
// broadcast only carries `like_count` — so a blind invert would drift
|
||
// `liked_by_me` until refresh. The response gives both, exactly.
|
||
const res = await api.post<{ liked: boolean; like_count: number | null }>(
|
||
`/upload/${id}/like`
|
||
);
|
||
vibrate(10);
|
||
// like_count is null when the server's count query hiccuped — keep the current
|
||
// count in that case rather than adopting a wrong number.
|
||
uploads = uploads.map((u) =>
|
||
u.id === id
|
||
? { ...u, liked_by_me: res.liked, like_count: res.like_count ?? u.like_count }
|
||
: u
|
||
);
|
||
if (selectedUpload?.id === id) {
|
||
selectedUpload = {
|
||
...selectedUpload,
|
||
liked_by_me: res.liked,
|
||
like_count: res.like_count ?? selectedUpload.like_count
|
||
};
|
||
}
|
||
} catch (e) {
|
||
toastError(e);
|
||
}
|
||
}
|
||
|
||
function openComments(id: string) {
|
||
const u = uploads.find((u) => u.id === id);
|
||
if (u) selectedUpload = u;
|
||
}
|
||
|
||
function selectSuggestion(item: Filter) {
|
||
if (!activeFilters.some((f) => f.type === item.type && f.value === item.value)) {
|
||
activeFilters = [...activeFilters, item];
|
||
}
|
||
searchQuery = '';
|
||
showAutocomplete = false;
|
||
}
|
||
|
||
function removeFilter(item: Filter) {
|
||
activeFilters = activeFilters.filter((f) => !(f.type === item.type && f.value === item.value));
|
||
}
|
||
|
||
function clearFilters() {
|
||
activeFilters = [];
|
||
searchQuery = '';
|
||
}
|
||
|
||
function switchView(mode: 'list' | 'grid') {
|
||
viewMode = mode;
|
||
if (mode === 'list') {
|
||
searchQuery = '';
|
||
showAutocomplete = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div
|
||
class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950"
|
||
use:pullToRefresh={{
|
||
onrefresh: pullRefresh,
|
||
onpull: (_, progress) => (pullProgress = progress),
|
||
disabled: initialLoading
|
||
}}
|
||
>
|
||
<!-- Live pull-progress indicator: grows during the drag, rotates past threshold,
|
||
swaps to a spinner once the network refresh kicks off. -->
|
||
{#if refreshing || pullProgress > 0}
|
||
<div
|
||
class="pointer-events-none fixed left-0 right-0 top-[calc(env(safe-area-inset-top)+0.5rem)] z-40 flex justify-center"
|
||
>
|
||
<div
|
||
class="rounded-full bg-white/90 px-3 py-1 text-xs font-medium text-blue-600 shadow transition-opacity dark:bg-gray-900/90 dark:text-blue-300"
|
||
style="opacity: {refreshing ? 1 : Math.min(1, pullProgress)}"
|
||
>
|
||
{#if refreshing}
|
||
<span class="inline-flex items-center gap-2">
|
||
<span
|
||
class="inline-block h-3 w-3 animate-spin rounded-full border-2 border-blue-200 border-t-blue-600 dark:border-blue-700 dark:border-t-blue-300"
|
||
></span>
|
||
Aktualisiere…
|
||
</span>
|
||
{:else}
|
||
<svg
|
||
class="inline-block h-4 w-4 transition-transform"
|
||
style="transform: rotate({Math.min(180, pullProgress * 180)}deg)"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="2.5"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3"
|
||
/>
|
||
</svg>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{#if feedStale}
|
||
<div
|
||
class="pointer-events-none fixed left-0 right-0 top-[calc(env(safe-area-inset-top)+0.5rem)] z-40 flex justify-center"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="pointer-events-auto rounded-full border border-primary-400 bg-primary-100 px-4 py-1.5 text-xs font-semibold text-primary-800 shadow-lg hover:border-primary-500 hover:bg-primary-200 dark:border-primary-500/50 dark:bg-primary-950/60 dark:text-primary-200"
|
||
onclick={() => {
|
||
feedStale = false;
|
||
void loadFeed(true);
|
||
}}
|
||
>
|
||
Neue Beiträge – tippen zum Aktualisieren
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
<!-- Sticky header — opaque fallback for browsers without backdrop-filter. -->
|
||
<div
|
||
class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 pt-[env(safe-area-inset-top)] backdrop-blur supports-[not(backdrop-filter:blur(0))]:bg-white dark:border-gray-800 dark:bg-gray-900/95 dark:supports-[not(backdrop-filter:blur(0))]:bg-gray-900"
|
||
>
|
||
<div class="mx-auto flex max-w-2xl items-center justify-between px-4 py-3">
|
||
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Galerie</h1>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<!-- Diashow entry — tablet/desktop only (mobile uses the Account page tile). -->
|
||
<button
|
||
onclick={() => goto('/diashow')}
|
||
class="hidden rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100 sm:inline-flex"
|
||
aria-label="Diashow starten"
|
||
title="Diashow"
|
||
>
|
||
<svg
|
||
class="h-5 w-5"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M3.75 7.5A2.25 2.25 0 0 1 6 5.25h12A2.25 2.25 0 0 1 20.25 7.5v9A2.25 2.25 0 0 1 18 18.75H6A2.25 2.25 0 0 1 3.75 16.5v-9Z"
|
||
/>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M10 9.75 14.5 12 10 14.25v-4.5Z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- List / Grid toggle -->
|
||
<div class="flex items-center gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
|
||
<button
|
||
onclick={() => switchView('list')}
|
||
class="rounded-md p-1.5 transition-colors {viewMode === 'list'
|
||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
|
||
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||
aria-label="Listenansicht"
|
||
>
|
||
<!-- bars-3 -->
|
||
<svg
|
||
class="h-5 w-5"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
onclick={() => switchView('grid')}
|
||
class="rounded-md p-1.5 transition-colors {viewMode === 'grid'
|
||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
|
||
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||
aria-label="Rasteransicht"
|
||
>
|
||
<!-- squares-2x2 -->
|
||
<svg
|
||
class="h-5 w-5"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- List view: hashtag chips -->
|
||
{#if viewMode === 'list'}
|
||
<div class="mx-auto max-w-2xl px-4 pb-2">
|
||
<HashtagChips {hashtags} selected={selectedHashtag} onselect={selectHashtag} />
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Grid view: search bar + autocomplete -->
|
||
{#if viewMode === 'grid'}
|
||
<div class="mx-auto max-w-2xl px-4 pb-3">
|
||
<div class="relative">
|
||
<div
|
||
class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 focus-within:border-blue-400 focus-within:bg-white focus-within:ring-1 focus-within:ring-blue-200 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-blue-500 dark:focus-within:bg-gray-800"
|
||
>
|
||
<svg
|
||
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
||
/>
|
||
</svg>
|
||
<input
|
||
type="search"
|
||
placeholder="Nutzer oder #Tag suchen…"
|
||
bind:value={searchQuery}
|
||
onfocus={(e) => {
|
||
openAutocomplete();
|
||
// Push the input above the virtual keyboard so suggestions stay visible.
|
||
(e.currentTarget as HTMLInputElement).scrollIntoView({
|
||
block: 'center',
|
||
behavior: 'smooth'
|
||
});
|
||
}}
|
||
onclick={openAutocomplete}
|
||
oninput={openAutocomplete}
|
||
onblur={() => setTimeout(() => (showAutocomplete = false), 150)}
|
||
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none dark:text-gray-100 dark:placeholder-gray-500"
|
||
/>
|
||
{#if searchQuery}
|
||
<button
|
||
onclick={() => {
|
||
searchQuery = '';
|
||
}}
|
||
class="shrink-0 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||
aria-label="Suche löschen"
|
||
>
|
||
<svg
|
||
class="h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Autocomplete dropdown -->
|
||
{#if showAutocomplete && suggestions.length > 0}
|
||
<!-- `preventDefault` on the container's mousedown stops the input from blurring, which
|
||
is what would otherwise close this dropdown (see the input's `onblur`) before a
|
||
click could land on a suggestion.
|
||
|
||
That matters because the suggestions used to commit on `onmousedown` — purely to
|
||
beat that blur. Two things were wrong with it. It fired on PRESS, so sliding off a
|
||
suggestion you didn't mean to hit still applied the filter, with no way to abort.
|
||
And `selectSuggestion` sets `showAutocomplete = false`, so the button DESTROYED
|
||
ITSELF on the first event of the click sequence: whether the node survived to
|
||
`mouseup` came down to whether Svelte's flush landed in between. That is the whole
|
||
reason this spec was flaky under load.
|
||
|
||
Suppressing the blur lets the handler move to `onclick` — the LAST event — so the
|
||
element self-destructing is harmless, and press-then-slide-off aborts like a button
|
||
should. -->
|
||
<div
|
||
class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
||
onmousedown={(e) => e.preventDefault()}
|
||
role="listbox"
|
||
tabindex="-1"
|
||
>
|
||
{#each suggestions as item (item.type + ':' + item.value)}
|
||
<button
|
||
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||
onclick={() => selectSuggestion(item)}
|
||
>
|
||
{#if item.type === 'user'}
|
||
<svg
|
||
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
|
||
/>
|
||
</svg>
|
||
<span class="font-medium text-gray-900 dark:text-gray-100">{item.value}</span>
|
||
{:else}
|
||
<span class="font-medium text-blue-500 dark:text-blue-400">#</span>
|
||
<span class="font-medium text-gray-900 dark:text-gray-100">{item.value}</span>
|
||
{/if}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Active filter chips -->
|
||
{#if activeFilters.length > 0}
|
||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||
{#each activeFilters as filter (filter.type + ':' + filter.value)}
|
||
<span
|
||
class="flex items-center gap-1 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
|
||
>
|
||
{filter.type === 'tag' ? '#' : ''}{filter.value}
|
||
<button
|
||
onclick={() => removeFilter(filter)}
|
||
class="ml-0.5 hover:text-blue-900 dark:hover:text-blue-100"
|
||
aria-label="Filter entfernen"
|
||
>
|
||
<svg
|
||
class="h-3 w-3"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="2.5"
|
||
>
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
{/each}
|
||
{#if activeFilters.length >= 2}
|
||
<button
|
||
onclick={clearFilters}
|
||
class="text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||
>
|
||
Alle löschen
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Download banner — appears for everyone (esp. guests) once the host has
|
||
released the gallery, so the keepsake download is discoverable from the feed
|
||
and not only via the Export tab. -->
|
||
{#if $exportStatus.released}
|
||
<div class="mx-auto max-w-2xl px-4 pt-3">
|
||
<a
|
||
href="/export"
|
||
data-testid="feed-export-banner"
|
||
class="flex items-center gap-3 rounded-2xl border border-primary-300 bg-primary-50 p-4 transition hover:bg-primary-100 dark:border-primary-800/60 dark:bg-primary-950/30 dark:hover:bg-primary-900/30"
|
||
>
|
||
<span
|
||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300"
|
||
>
|
||
<svg
|
||
class="h-5 w-5"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||
/>
|
||
</svg>
|
||
</span>
|
||
<span class="min-w-0 flex-1">
|
||
<span class="block font-semibold text-primary-900 dark:text-primary-100"
|
||
>Galerie herunterladen</span
|
||
>
|
||
<span class="block text-sm text-primary-800/80 dark:text-primary-300/80"
|
||
>Alle Fotos als ZIP und als Offline-Album sichern.</span
|
||
>
|
||
</span>
|
||
<svg
|
||
class="h-5 w-5 shrink-0 text-primary-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||
</svg>
|
||
</a>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Content -->
|
||
{#if initialLoading && uploads.length === 0}
|
||
<div class="mx-auto max-w-2xl" data-testid="feed-skeleton">
|
||
{#if viewMode === 'list'}
|
||
{#each Array(3) as _, i (i)}
|
||
<Skeleton variant="card" />
|
||
{/each}
|
||
{:else}
|
||
<div class="grid grid-cols-3 gap-0.5">
|
||
{#each Array(9) as _, i (i)}
|
||
<Skeleton variant="tile" />
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else if uploads.length === 0}
|
||
<div class="py-20 text-center">
|
||
<p class="text-lg text-gray-400 dark:text-gray-500">Noch keine Fotos.</p>
|
||
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Tippe auf den Plus-Button unten!</p>
|
||
</div>
|
||
{:else if viewMode === 'list'}
|
||
<!-- List view: chronological full-width cards (DOM-windowed) -->
|
||
<div class="mx-auto max-w-2xl">
|
||
<VirtualFeed
|
||
mode="list"
|
||
{uploads}
|
||
{myUserId}
|
||
onlike={handleLike}
|
||
oncomment={openComments}
|
||
onselect={(u) => (selectedUpload = u)}
|
||
oncontextmenu={openContextSheet}
|
||
/>
|
||
</div>
|
||
{:else}
|
||
<!-- Grid view: 3-col, filters applied (DOM-windowed by row) -->
|
||
<div class="mx-auto max-w-2xl">
|
||
{#if displayUploads.length === 0}
|
||
<div class="py-16 text-center">
|
||
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||
Keine Treffer für die gewählten Filter.
|
||
</p>
|
||
{#if nextCursor}
|
||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||
Es sind noch nicht alle Beiträge geladen — scrolle weiter, um mehr zu durchsuchen.
|
||
</p>
|
||
{/if}
|
||
<button onclick={clearFilters} class="btn btn-ghost btn-sm mt-2"
|
||
>Filter zurücksetzen</button
|
||
>
|
||
</div>
|
||
{:else}
|
||
<VirtualFeed
|
||
mode="grid"
|
||
uploads={displayUploads}
|
||
{myUserId}
|
||
onlike={handleLike}
|
||
oncomment={openComments}
|
||
onselect={(u) => (selectedUpload = u)}
|
||
oncontextmenu={openContextSheet}
|
||
/>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Infinite scroll sentinel -->
|
||
<div class="mx-auto max-w-2xl">
|
||
<div bind:this={sentinel} class="h-4"></div>
|
||
{#if loadingMore}
|
||
<div class="py-4 text-center">
|
||
<div
|
||
class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600 dark:border-gray-700 dark:border-t-blue-400"
|
||
></div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Lightbox -->
|
||
{#if selectedUpload}
|
||
<LightboxModal
|
||
upload={selectedUpload}
|
||
onclose={() => (selectedUpload = null)}
|
||
onlike={handleLike}
|
||
/>
|
||
{/if}
|
||
|
||
<!-- Context sheet for post long-press / kebab tap -->
|
||
<ContextSheet
|
||
open={contextTarget !== null}
|
||
actions={contextActions}
|
||
onClose={() => (contextTarget = null)}
|
||
/>
|
||
|
||
<!-- Branded delete confirmation — replaces window.confirm() -->
|
||
<ConfirmSheet
|
||
open={pendingDelete !== null}
|
||
title={pendingDelete?.asHost ? 'Beitrag entfernen?' : 'Beitrag löschen?'}
|
||
message={pendingDelete?.asHost
|
||
? 'Der Beitrag verschwindet für alle Gäste. Diese Aktion kann nicht rückgängig gemacht werden.'
|
||
: 'Diese Aktion kann nicht rückgängig gemacht werden.'}
|
||
confirmLabel={pendingDelete?.asHost ? 'Entfernen' : 'Löschen'}
|
||
tone="danger"
|
||
onConfirm={confirmDelete}
|
||
onCancel={() => (pendingDelete = null)}
|
||
/>
|
||
|
||
<!-- First-visit onboarding guide -->
|
||
<OnboardingGuide />
|