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>
364 lines
11 KiB
Svelte
364 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { vibrate } from '$lib/haptics';
|
|
|
|
interface Props {
|
|
oncapture: (blob: Blob, type: 'photo' | 'video') => void;
|
|
onclose: () => void;
|
|
/// Fired once, when the live preview is actually on screen — not when the camera was
|
|
/// merely requested. The caller uses it to dismiss whatever launched the camera.
|
|
///
|
|
/// Gated on the preview rather than on `getUserMedia` resolving, because the two differ
|
|
/// exactly where it matters: if permission is denied, or the page is not a secure context,
|
|
/// this never fires and the caller's UI stays put behind the error panel — so "Schließen"
|
|
/// 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, 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
|
|
/// dismissal it has already done.
|
|
let announcedReady = false;
|
|
|
|
function handlePreviewLive() {
|
|
if (announcedReady) return;
|
|
announcedReady = true;
|
|
onready?.();
|
|
}
|
|
|
|
let videoEl: HTMLVideoElement = $state()!;
|
|
let canvasEl: HTMLCanvasElement = $state()!;
|
|
let stream: MediaStream | null = $state(null);
|
|
let facingMode = $state<'environment' | 'user'>('environment');
|
|
let mode = $state<'photo' | 'video'>('photo');
|
|
let recording = $state(false);
|
|
let recordingTime = $state(0);
|
|
let error = $state<string | null>(null);
|
|
let mediaRecorder: MediaRecorder | null = null;
|
|
let recordedChunks: Blob[] = [];
|
|
let recordingInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
function handleShutter() {
|
|
vibrate(15);
|
|
if (mode === 'photo') {
|
|
capturePhoto();
|
|
} else if (recording) {
|
|
stopRecording();
|
|
} else {
|
|
startRecording();
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
startCamera();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
stopCamera();
|
|
if (recordingInterval) clearInterval(recordingInterval);
|
|
});
|
|
|
|
async function startCamera() {
|
|
error = null;
|
|
stopCamera();
|
|
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode, width: { ideal: 1920 }, height: { ideal: 1080 } },
|
|
// Only request the mic for video — a photo-only session shouldn't trigger
|
|
// a mic permission prompt (which also blocks capture on mic-less devices).
|
|
audio: mode === 'video'
|
|
});
|
|
if (videoEl) {
|
|
videoEl.srcObject = stream;
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'NotAllowedError') {
|
|
error =
|
|
'Kamerazugriff wurde verweigert. Bitte erlaube den Zugriff in den Browsereinstellungen.';
|
|
} else if (err instanceof DOMException && err.name === 'NotFoundError') {
|
|
error = 'Keine Kamera gefunden.';
|
|
} else {
|
|
error = 'Kamera konnte nicht gestartet werden.';
|
|
}
|
|
}
|
|
}
|
|
|
|
function stopCamera() {
|
|
if (stream) {
|
|
for (const track of stream.getTracks()) {
|
|
track.stop();
|
|
}
|
|
stream = null;
|
|
}
|
|
}
|
|
|
|
async function toggleCamera() {
|
|
facingMode = facingMode === 'environment' ? 'user' : 'environment';
|
|
await startCamera();
|
|
}
|
|
|
|
// Switching between photo and video changes whether we need the mic, so
|
|
// re-acquire the stream (lazily adding the mic for video, dropping it for photo).
|
|
async function setMode(next: 'photo' | 'video') {
|
|
if (mode === next) return;
|
|
mode = next;
|
|
await startCamera();
|
|
}
|
|
|
|
function capturePhoto() {
|
|
if (!videoEl || !canvasEl) return;
|
|
const ctx = canvasEl.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
canvasEl.width = videoEl.videoWidth;
|
|
canvasEl.height = videoEl.videoHeight;
|
|
ctx.drawImage(videoEl, 0, 0);
|
|
|
|
canvasEl.toBlob(
|
|
(blob) => {
|
|
if (blob) oncapture(blob, 'photo');
|
|
},
|
|
'image/jpeg',
|
|
0.92
|
|
);
|
|
}
|
|
|
|
function startRecording() {
|
|
if (!stream) return;
|
|
|
|
recordedChunks = [];
|
|
recordingTime = 0;
|
|
|
|
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
|
? 'video/webm;codecs=vp9'
|
|
: MediaRecorder.isTypeSupported('video/webm')
|
|
? 'video/webm'
|
|
: 'video/mp4';
|
|
|
|
mediaRecorder = new MediaRecorder(stream, { mimeType });
|
|
|
|
mediaRecorder.ondataavailable = (e) => {
|
|
if (e.data.size > 0) recordedChunks.push(e.data);
|
|
};
|
|
|
|
mediaRecorder.onstop = () => {
|
|
const blob = new Blob(recordedChunks, { type: mediaRecorder?.mimeType ?? mimeType });
|
|
oncapture(blob, 'video');
|
|
recordedChunks = [];
|
|
};
|
|
|
|
mediaRecorder.start(1000);
|
|
recording = true;
|
|
|
|
recordingInterval = setInterval(() => {
|
|
recordingTime += 1;
|
|
}, 1000);
|
|
}
|
|
|
|
function stopRecording() {
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
mediaRecorder.stop();
|
|
}
|
|
recording = false;
|
|
if (recordingInterval) {
|
|
clearInterval(recordingInterval);
|
|
recordingInterval = null;
|
|
}
|
|
}
|
|
|
|
function formatRecordingTime(seconds: number): string {
|
|
const m = Math.floor(seconds / 60);
|
|
const s = seconds % 60;
|
|
return `${m}:${String(s).padStart(2, '0')}`;
|
|
}
|
|
</script>
|
|
|
|
<!-- z-[60], above the z-50 upload sheet that launches this. The sheet stays mounted for its
|
|
translate-y animation and is rendered AFTER this element, so at an equal z-index it painted
|
|
on top: the shutter button sat underneath the Galerie/Kamera panel and the only way to reach
|
|
it was the phone's back button. The sheet also dismisses itself once the preview is live
|
|
(see `onready`), but that is a behaviour; this is the guarantee — the capture controls are
|
|
reachable during the permission prompt and on the error panel too, when nothing has been
|
|
dismissed yet. -->
|
|
<div class="fixed inset-0 z-[60] flex flex-col bg-black">
|
|
<!-- Camera preview -->
|
|
<div class="relative flex-1 overflow-hidden">
|
|
{#if error}
|
|
<div class="flex h-full items-center justify-center p-8">
|
|
<div class="rounded-lg bg-gray-900 p-6 text-center">
|
|
<svg
|
|
class="mx-auto mb-3 h-12 w-12 text-red-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="1.5"
|
|
d="M15 10l-4 4m0-4l4 4m6-4a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<p class="text-sm text-white">{error}</p>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<!-- svelte-ignore a11y_media_has_caption -->
|
|
<video
|
|
bind:this={videoEl}
|
|
autoplay
|
|
playsinline
|
|
muted
|
|
onloadedmetadata={handlePreviewLive}
|
|
class="h-full w-full object-cover {facingMode === 'user' ? 'scale-x-[-1]' : ''}"
|
|
></video>
|
|
|
|
{#if recording}
|
|
<div
|
|
class="absolute left-4 top-4 flex items-center gap-2 rounded-full bg-red-600 px-3 py-1"
|
|
>
|
|
<div class="h-2 w-2 animate-pulse rounded-full bg-white"></div>
|
|
<span class="text-sm font-medium text-white">{formatRecordingTime(recordingTime)}</span>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Controls -->
|
|
{#if !error}
|
|
<!-- Mode toggle — segmented control above the shutter. Hidden while recording. -->
|
|
{#if !recording}
|
|
<div class="flex justify-center bg-black/80 pt-3 pb-1">
|
|
<div
|
|
class="inline-flex rounded-full bg-white/10 p-0.5"
|
|
role="tablist"
|
|
aria-label="Aufnahmemodus"
|
|
data-testid="camera-mode"
|
|
>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={mode === 'photo'}
|
|
onclick={() => setMode('photo')}
|
|
data-testid="camera-mode-photo"
|
|
class="rounded-full px-4 py-1 text-sm font-medium transition {mode === 'photo'
|
|
? 'bg-white text-gray-900'
|
|
: 'text-white/70 hover:text-white active:text-white'}"
|
|
>
|
|
Foto
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={mode === 'video'}
|
|
onclick={() => setMode('video')}
|
|
data-testid="camera-mode-video"
|
|
class="rounded-full px-4 py-1 text-sm font-medium transition {mode === 'video'
|
|
? 'bg-white text-gray-900'
|
|
: 'text-white/70 hover:text-white active:text-white'}"
|
|
>
|
|
Video
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div
|
|
class="flex items-center justify-center gap-8 bg-black/80 px-4 pt-4 pb-6"
|
|
style="padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem)"
|
|
>
|
|
<!-- Close -->
|
|
<button
|
|
onclick={onclose}
|
|
class="flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-white transition active:bg-white/30"
|
|
aria-label="Schließen"
|
|
>
|
|
<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="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Shutter — single button, behaviour depends on mode + recording state. -->
|
|
<button
|
|
onclick={handleShutter}
|
|
data-testid="camera-shutter"
|
|
class="flex h-16 w-16 items-center justify-center rounded-full border-4 border-white transition active:scale-95 {recording
|
|
? 'bg-red-600'
|
|
: 'bg-white/20'}"
|
|
aria-label={mode === 'photo'
|
|
? 'Foto aufnehmen'
|
|
: recording
|
|
? 'Aufnahme stoppen'
|
|
: 'Video aufnehmen'}
|
|
>
|
|
{#if mode === 'photo'}
|
|
<div class="h-12 w-12 rounded-full bg-white"></div>
|
|
{:else if recording}
|
|
<div class="h-6 w-6 rounded-sm bg-white"></div>
|
|
{:else}
|
|
<div class="h-10 w-10 rounded-full bg-red-500"></div>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Toggle camera (hidden while recording to discourage interruption). -->
|
|
{#if recording}
|
|
<div class="h-12 w-12"></div>
|
|
{:else}
|
|
<button
|
|
onclick={toggleCamera}
|
|
class="flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-white transition active:bg-white/30"
|
|
aria-label="Kamera wechseln"
|
|
>
|
|
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Hidden canvas for photo capture -->
|
|
<canvas bind:this={canvasEl} class="hidden"></canvas>
|