fix: close the last three guest-facing dead ends (items 7-9)
C1 — a failed page-append silently ended infinite scroll `loadMore`'s catch showed a toast and changed no state, unlike every sibling error path in the file. `nextCursor` survived so the feed was still technically paginable, but the IntersectionObserver only fires on a CHANGE: after a failed append nothing scrolls and no rows are added, so it never re-fires. One 429 or wifi blip and the guest concluded the gallery was 20 photos. Now leaves a retry control at the sentinel — a toast that fades in 5s is not an affordance — resuming from the untouched cursor. C2 — the export page reported downloads that never happened `downloadFile` toasted 'Download gestartet' the instant it assigned the iframe's src, before a single byte existed. Since the iframe swallows errors BY DESIGN (a top-level navigation to a 404 would unload the PWA), a failure produced a green success message, a consumed single-use ticket, and one of only three daily slots spent — repeatable until the day's allowance was gone, on the screen that is the whole point of the app. Root cause is two sources of truth: `export_status` reports `done` from `export_job` and enables the button, while the download resolves through `export_current.file_path` plus a `Path::exists()`. They can legitimately disagree. `export_ticket` now takes a `kind` and calls the existing `resolve_export_file` BEFORE charging the rate slot, so a missing archive fails honestly on a plain fetch that `toastError` already renders. Not the HEAD probe ruled out elsewhere: it reads the same indexed row the download will read and touches no ticket, so it cannot consume anything. The parameter is optional, so an older client degrades to today's behaviour rather than breaking. C3 — the WhatsApp journey could dead-end with no error at all The join link travels through guest group chats, and a link tapped inside one opens in that app's browser, where the file picker and getUserMedia both depend on the host app having wired them up. When they aren't, the buttons do nothing — no error, nothing to act on. Two targeted changes rather than a UI rebuild: the camera error panel now offers "Aus Galerie wählen" (its advice to change "Browsereinstellungen" refers to settings that do not exist in a webview, so retrying could never help those guests), and the sheet carries a standing one-line hint to open the link in Safari or Chrome. Deliberately no user-agent sniffing: a sniff list is wrong for every browser it has not heard of, while a quiet standing hint costs one line and is never wrong. The hint lives in UploadSheet rather than the root layout because both layout banners are gated on `$showBottomNav`, which `/upload` turns off — one there would never render on the composer. Verified: 151/151 backend tests against a live Postgres, clippy clean, 58/58 vitest, svelte-check 0 errors, eslint clean, both builds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -115,11 +115,20 @@
|
||||
// the GET route, so a probe would consume the ticket and burn a rate-limit slot.
|
||||
let downloadFrame: HTMLIFrameElement;
|
||||
|
||||
async function downloadFile(endpoint: string) {
|
||||
async function downloadFile(endpoint: string, kind: 'zip' | 'html') {
|
||||
if (downloading) return;
|
||||
downloading = true;
|
||||
try {
|
||||
const { ticket } = await api.post<{ ticket: string }>('/export/ticket');
|
||||
// `kind` makes the mint check that THIS archive exists before charging a slot.
|
||||
// `export_status` reports readiness from `export_job`, while the download resolves
|
||||
// through `export_current` + the filesystem — different sources of truth that can
|
||||
// legitimately disagree (a retired epoch, a file swept from under a done row). When
|
||||
// they did, the guest got a green success toast, a consumed single-use ticket, one
|
||||
// of three daily slots gone, and nothing in their Downloads. Now the mint fails
|
||||
// honestly and lands in the `catch` below.
|
||||
const { ticket } = await api.post<{ ticket: string }>(
|
||||
`/export/ticket?kind=${kind}`
|
||||
);
|
||||
downloadFrame.src = `${endpoint}?ticket=${encodeURIComponent(ticket)}`;
|
||||
// An iframe download produces NO visible change: no spinner, no navigation, and
|
||||
// on mobile often no browser chrome either. Without a word here the guest cannot
|
||||
@@ -142,12 +151,12 @@
|
||||
}
|
||||
|
||||
function downloadZip() {
|
||||
downloadFile('/api/v1/export/zip');
|
||||
downloadFile('/api/v1/export/zip', 'zip');
|
||||
}
|
||||
|
||||
function downloadHtml() {
|
||||
if (localStorage.getItem(HTML_GUIDE_KEY)) {
|
||||
downloadFile('/api/v1/export/html');
|
||||
downloadFile('/api/v1/export/html', 'html');
|
||||
} else {
|
||||
showHtmlGuide = true;
|
||||
}
|
||||
@@ -156,7 +165,7 @@
|
||||
function confirmHtmlDownload() {
|
||||
localStorage.setItem(HTML_GUIDE_KEY, '1');
|
||||
showHtmlGuide = false;
|
||||
downloadFile('/api/v1/export/html');
|
||||
downloadFile('/api/v1/export/html', 'html');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
let selectedHashtag = $state<string | null>(null);
|
||||
let nextCursor = $state<string | null>(null);
|
||||
let loadingMore = $state(false);
|
||||
// Set when a page-append fails. Without it the feed silently stops paginating: the
|
||||
// IntersectionObserver only fires on a CHANGE, and after a failed append nothing scrolls and
|
||||
// no rows are added, so it never re-fires. A single 429 or wifi blip ended infinite scroll for
|
||||
// the session and the guest concluded the gallery was 20 photos.
|
||||
let loadMoreError = $state(false);
|
||||
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
|
||||
@@ -639,7 +644,11 @@
|
||||
const present = new Set(uploads.map((u) => u.id));
|
||||
uploads = [...uploads, ...res.uploads.filter((u) => !present.has(u.id))];
|
||||
nextCursor = res.next_cursor;
|
||||
loadMoreError = false;
|
||||
} catch (e) {
|
||||
// Leave a CONTROL on screen, not just a toast that fades in 5s. `nextCursor` is
|
||||
// deliberately untouched so the retry resumes from exactly where this failed.
|
||||
loadMoreError = true;
|
||||
toastError(e);
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
@@ -1295,7 +1304,16 @@
|
||||
<!-- Infinite scroll sentinel -->
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div bind:this={sentinel} class="h-4"></div>
|
||||
{#if loadingMore}
|
||||
{#if loadMoreError && !loadingMore}
|
||||
<div class="py-6 text-center" data-testid="feed-load-more-error">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Weitere Fotos konnten nicht geladen werden.
|
||||
</p>
|
||||
<button onclick={() => void loadMore()} class="btn btn-primary btn-sm mt-3">
|
||||
Erneut laden
|
||||
</button>
|
||||
</div>
|
||||
{:else 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"
|
||||
|
||||
Reference in New Issue
Block a user