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:
@@ -14,9 +14,17 @@
|
||||
/// returns the guest to where they were instead of dropping them somewhere they never
|
||||
/// asked to be.
|
||||
onready?: () => void;
|
||||
/// Fired when the guest gives up on the camera and wants the file picker instead.
|
||||
///
|
||||
/// The error panel used to offer only "Erneut versuchen" and "Schließen", which dead-ends
|
||||
/// the whole upload journey for anyone whose `getUserMedia` cannot succeed: an in-app
|
||||
/// browser (the WhatsApp/Instagram webview a shared QR link opens in) may never grant it,
|
||||
/// and the panel's advice to change "Browsereinstellungen" refers to settings that do not
|
||||
/// exist there. Retrying cannot help those guests; picking a photo can.
|
||||
onpickfile?: () => void;
|
||||
}
|
||||
|
||||
let { oncapture, onclose, onready }: Props = $props();
|
||||
let { oncapture, onclose, onready, onpickfile }: Props = $props();
|
||||
|
||||
/// `onready` is a one-shot. `startCamera` re-runs on every lens flip and photo/video switch,
|
||||
/// and re-announcing "the camera is ready" mid-session would ask the caller to redo a
|
||||
@@ -204,13 +212,21 @@
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm text-white">{error}</p>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<div class="mt-4 flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
onclick={startCamera}
|
||||
class="rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-900"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
{#if onpickfile}
|
||||
<button
|
||||
onclick={onpickfile}
|
||||
class="rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-900"
|
||||
>
|
||||
Aus Galerie wählen
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={onclose} class="rounded-lg bg-white/20 px-4 py-2 text-sm text-white">
|
||||
Schließen
|
||||
</button>
|
||||
|
||||
@@ -156,6 +156,12 @@
|
||||
oncapture={handleCapture}
|
||||
onclose={handleCameraClose}
|
||||
onready={handleCameraReady}
|
||||
onpickfile={() => {
|
||||
// Close the camera first, then open the OS picker — the sheet is still mounted
|
||||
// behind it, so this returns the guest to a working path instead of a dead end.
|
||||
showCamera = false;
|
||||
openGallery();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -288,6 +294,20 @@
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- The in-app-browser escape hatch.
|
||||
The join link travels through WhatsApp groups, and a link tapped inside one opens
|
||||
in that app's own browser rather than Safari or Chrome. There, the file picker and
|
||||
`getUserMedia` both depend on the host app having wired them up, and when they are
|
||||
not wired up the buttons above simply do nothing — no error, nothing to act on,
|
||||
with no operator to ask. Deliberately NOT user-agent sniffing: a sniff list is
|
||||
wrong for browsers it has never heard of, whereas a quiet standing hint costs a
|
||||
line of text and is never wrong. It lives here rather than in the root layout
|
||||
because both layout banners are gated on `$showBottomNav`, which `/upload` turns
|
||||
off — a banner there would never render on the composer. -->
|
||||
<p class="px-1 pt-1 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
Nichts passiert beim Tippen? Öffne den Link in Safari oder Chrome.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Queue access. The FAB badge is the ONLY signal a guest gets that an upload is
|
||||
|
||||
@@ -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