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>
This commit is contained in:
45
.env.test
Normal file
45
.env.test
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# ── Domain ────────────────────────────────────────────────────────────────────
|
||||||
|
# Public domain Caddy will serve and obtain a TLS certificate for.
|
||||||
|
DOMAIN=my-event.example.com
|
||||||
|
|
||||||
|
# ── App server ────────────────────────────────────────────────────────────────
|
||||||
|
APP_PORT=3000
|
||||||
|
|
||||||
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
|
DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap
|
||||||
|
POSTGRES_USER=eventsnap
|
||||||
|
POSTGRES_PASSWORD=secret
|
||||||
|
POSTGRES_DB=eventsnap
|
||||||
|
|
||||||
|
# ── Authentication ────────────────────────────────────────────────────────────
|
||||||
|
# Generate with: openssl rand -hex 64
|
||||||
|
JWT_SECRET=change_me_to_a_random_64_byte_hex_string
|
||||||
|
SESSION_EXPIRY_DAYS=30
|
||||||
|
|
||||||
|
# Admin dashboard password (bcrypt hash).
|
||||||
|
# Generate with: htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
|
||||||
|
ADMIN_PASSWORD_HASH=$2y$12$placeholder_replace_me
|
||||||
|
|
||||||
|
# ── Event ─────────────────────────────────────────────────────────────────────
|
||||||
|
EVENT_NAME=Max & Maria's Wedding
|
||||||
|
EVENT_SLUG=max-maria-2026
|
||||||
|
|
||||||
|
# ── Storage ───────────────────────────────────────────────────────────────────
|
||||||
|
MEDIA_PATH=/media
|
||||||
|
|
||||||
|
# ── Upload limits ─────────────────────────────────────────────────────────────
|
||||||
|
DEFAULT_MAX_IMAGE_SIZE_MB=20
|
||||||
|
DEFAULT_MAX_VIDEO_SIZE_MB=500
|
||||||
|
|
||||||
|
# ── Rate limiting ─────────────────────────────────────────────────────────────
|
||||||
|
DEFAULT_UPLOAD_RATE_PER_HOUR=10
|
||||||
|
DEFAULT_FEED_RATE_PER_MIN=60
|
||||||
|
DEFAULT_EXPORT_RATE_PER_DAY=3
|
||||||
|
|
||||||
|
# ── Capacity ──────────────────────────────────────────────────────────────────
|
||||||
|
DEFAULT_ESTIMATED_GUEST_COUNT=100
|
||||||
|
# Fraction of total storage that triggers the "low storage" warning (0.0–1.0)
|
||||||
|
DEFAULT_QUOTA_TOLERANCE=0.75
|
||||||
|
|
||||||
|
# ── Workers ───────────────────────────────────────────────────────────────────
|
||||||
|
COMPRESSION_WORKER_CONCURRENCY=2
|
||||||
238
frontend/src/lib/components/CameraCapture.svelte
Normal file
238
frontend/src/lib/components/CameraCapture.svelte
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
oncapture: (blob: Blob, type: 'photo' | 'video') => void;
|
||||||
|
onclose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { oncapture, onclose }: Props = $props();
|
||||||
|
|
||||||
|
let videoEl: HTMLVideoElement = $state()!;
|
||||||
|
let canvasEl: HTMLCanvasElement = $state()!;
|
||||||
|
let stream: MediaStream | null = $state(null);
|
||||||
|
let facingMode = $state<'environment' | 'user'>('environment');
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 } },
|
||||||
|
audio: true
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div class="fixed inset-0 z-50 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>
|
||||||
|
<button
|
||||||
|
onclick={onclose}
|
||||||
|
class="mt-4 rounded-lg bg-white/20 px-4 py-2 text-sm text-white"
|
||||||
|
>
|
||||||
|
Schliessen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video
|
||||||
|
bind:this={videoEl}
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
muted
|
||||||
|
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}
|
||||||
|
<div class="flex items-center justify-center gap-8 bg-black/80 px-4 py-6">
|
||||||
|
<!-- Close -->
|
||||||
|
<button
|
||||||
|
onclick={onclose}
|
||||||
|
class="flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-white"
|
||||||
|
aria-label="Schliessen"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Capture photo / record video -->
|
||||||
|
{#if recording}
|
||||||
|
<button
|
||||||
|
onclick={stopRecording}
|
||||||
|
class="flex h-16 w-16 items-center justify-center rounded-full border-4 border-white bg-red-600"
|
||||||
|
aria-label="Aufnahme stoppen"
|
||||||
|
>
|
||||||
|
<div class="h-6 w-6 rounded-sm bg-white"></div>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={capturePhoto}
|
||||||
|
class="flex h-16 w-16 items-center justify-center rounded-full border-4 border-white bg-white/20 transition active:bg-white/40"
|
||||||
|
aria-label="Foto aufnehmen"
|
||||||
|
>
|
||||||
|
<div class="h-12 w-12 rounded-full bg-white"></div>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Toggle camera / start recording -->
|
||||||
|
{#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"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Video record button -->
|
||||||
|
{#if !recording}
|
||||||
|
<div class="flex justify-center bg-black/80 pb-4">
|
||||||
|
<button
|
||||||
|
onclick={startRecording}
|
||||||
|
class="flex items-center gap-2 rounded-full bg-red-600/80 px-4 py-2 text-sm text-white transition hover:bg-red-600"
|
||||||
|
>
|
||||||
|
<div class="h-2.5 w-2.5 rounded-full bg-white"></div>
|
||||||
|
Video aufnehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden canvas for photo capture -->
|
||||||
|
<canvas bind:this={canvasEl} class="hidden"></canvas>
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user