1 Commits

Author SHA1 Message Date
MechaCat02
25f4fb1810 feat: implement camera capture step
Add in-app camera capture to the upload flow. Guests can now take photos
and record videos directly via getUserMedia without leaving the app.
The captured media is immediately queued through the existing IndexedDB
upload pipeline alongside library-picked files.

- CameraCapture.svelte: fullscreen overlay with live preview, photo
  capture (JPEG via canvas), video recording (WebM/MP4 via MediaRecorder),
  front/back camera toggle, recording timer, and permission-denied error state
- Upload page: side-by-side "Gallery" and "Camera" pickers; shared
  caption/hashtags fields apply to both sources; Blob→File conversion
  with timestamped filename before enqueue
- .env.test: reference environment config for local testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:20:51 +02:00
2 changed files with 52 additions and 19 deletions

View File

@@ -8,8 +8,8 @@
let { oncapture, onclose }: Props = $props(); let { oncapture, onclose }: Props = $props();
let videoEl: HTMLVideoElement; let videoEl: HTMLVideoElement = $state()!;
let canvasEl: HTMLCanvasElement; let canvasEl: HTMLCanvasElement = $state()!;
let stream: MediaStream | null = $state(null); let stream: MediaStream | null = $state(null);
let facingMode = $state<'environment' | 'user'>('environment'); let facingMode = $state<'environment' | 'user'>('environment');
let recording = $state(false); let recording = $state(false);

View File

@@ -3,11 +3,13 @@
import { getToken } from '$lib/auth'; import { getToken } from '$lib/auth';
import { addToQueue, loadQueue } from '$lib/upload-queue'; import { addToQueue, loadQueue } from '$lib/upload-queue';
import UploadQueue from '$lib/components/UploadQueue.svelte'; import UploadQueue from '$lib/components/UploadQueue.svelte';
import CameraCapture from '$lib/components/CameraCapture.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let caption = $state(''); let caption = $state('');
let hashtags = $state(''); let hashtags = $state('');
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
let showCamera = $state(false);
onMount(() => { onMount(() => {
if (!getToken()) { if (!getToken()) {
@@ -30,8 +32,23 @@
hashtags = ''; hashtags = '';
if (fileInput) fileInput.value = ''; if (fileInput) fileInput.value = '';
} }
async function handleCapture(blob: Blob, type: 'photo' | 'video') {
const ext = type === 'photo' ? 'jpg' : blob.type.includes('mp4') ? 'mp4' : 'webm';
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = `${type}_${timestamp}.${ext}`;
const file = new File([blob], fileName, { type: blob.type });
await addToQueue(file, caption, hashtags);
}
</script> </script>
{#if showCamera}
<CameraCapture
oncapture={handleCapture}
onclose={() => (showCamera = false)}
/>
{/if}
<div class="min-h-screen bg-gray-50 p-4"> <div class="min-h-screen bg-gray-50 p-4">
<div class="mx-auto max-w-lg"> <div class="mx-auto max-w-lg">
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
@@ -40,23 +57,39 @@
</div> </div>
<div class="rounded-lg border border-gray-200 bg-white p-4"> <div class="rounded-lg border border-gray-200 bg-white p-4">
<label <div class="grid grid-cols-2 gap-3">
class="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 transition hover:border-blue-400 hover:bg-blue-50" <!-- File picker -->
> <label
<svg class="mb-2 h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> class="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6 transition hover:border-blue-400 hover:bg-blue-50"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 16V4m0 0l-4 4m4-4l4 4M4 20h16" /> >
</svg> <svg class="mb-2 h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<span class="text-sm font-medium text-gray-600">Fotos oder Videos auswählen</span> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 16V4m0 0l-4 4m4-4l4 4M4 20h16" />
<span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span> </svg>
<input <span class="text-center text-sm font-medium text-gray-600">Galerie</span>
bind:this={fileInput} <span class="mt-1 text-center text-xs text-gray-400">Mehrere Dateien</span>
type="file" <input
accept="image/*,video/*" bind:this={fileInput}
multiple type="file"
class="hidden" accept="image/*,video/*"
onchange={handleFiles} multiple
/> class="hidden"
</label> onchange={handleFiles}
/>
</label>
<!-- Camera button -->
<button
onclick={() => (showCamera = true)}
class="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6 transition hover:border-blue-400 hover:bg-blue-50"
>
<svg class="mb-2 h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="text-sm font-medium text-gray-600">Kamera</span>
<span class="mt-1 text-xs text-gray-400">Foto & Video</span>
</button>
</div>
<div class="mt-4 space-y-3"> <div class="mt-4 space-y-3">
<input <input