472 lines
17 KiB
TypeScript
472 lines
17 KiB
TypeScript
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
|
import { Page } from './_util';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useAppearance } from '../../hooks/use-appearance';
|
|
import { Camera, RotateCcw, Zap, ZapOff } from 'lucide-react';
|
|
import BottomNav from '../components/BottomNav';
|
|
import { uploadPhoto } from '../services/photosApi';
|
|
|
|
interface Task {
|
|
id: number;
|
|
title: string;
|
|
description: string;
|
|
instructions?: string;
|
|
duration: number;
|
|
emotion?: { slug: string; name: string };
|
|
difficulty?: 'easy' | 'medium' | 'hard';
|
|
}
|
|
|
|
export default function UploadPage() {
|
|
const { slug } = useParams<{ slug: string }>();
|
|
const [searchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
const { appearance } = useAppearance();
|
|
const isDark = appearance === 'dark';
|
|
|
|
// Task data from URL params
|
|
const taskId = searchParams.get('task');
|
|
const emotionSlug = searchParams.get('emotion') || '';
|
|
const [task, setTask] = useState<Task | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
// Camera state
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
|
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user'); // front = user, back = environment
|
|
const [flashOn, setFlashOn] = useState(false);
|
|
const [isPulsing, setIsPulsing] = useState(false);
|
|
const [countdown, setCountdown] = useState(3);
|
|
const [capturing, setCapturing] = useState(false);
|
|
|
|
// Load task data from API
|
|
useEffect(() => {
|
|
if (!slug || !taskId) {
|
|
setError('Keine Aufgabendaten gefunden');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const taskIdNum = parseInt(taskId);
|
|
if (isNaN(taskIdNum)) {
|
|
setError('Ungültige Aufgaben-ID');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
async function fetchTask() {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const response = await fetch(`/api/v1/events/${slug}/tasks`);
|
|
if (!response.ok) throw new Error('Tasks konnten nicht geladen werden');
|
|
|
|
const tasks = await response.json();
|
|
const foundTask = tasks.find((t: any) => t.id === taskIdNum);
|
|
|
|
if (foundTask) {
|
|
setTask({
|
|
id: foundTask.id,
|
|
title: foundTask.title || `Aufgabe ${taskIdNum}`,
|
|
description: foundTask.description || 'Stelle dich für das Foto auf und lächle in die Kamera.',
|
|
instructions: foundTask.instructions,
|
|
duration: foundTask.duration || 2,
|
|
emotion: foundTask.emotion,
|
|
difficulty: 'medium' as const
|
|
});
|
|
} else {
|
|
// Fallback for unknown task ID
|
|
setTask({
|
|
id: taskIdNum,
|
|
title: `Unbekannte Aufgabe ${taskIdNum}`,
|
|
description: 'Stelle dich für das Foto auf und lächle in die Kamera.',
|
|
instructions: 'Positioniere dich gut und warte auf den Countdown.',
|
|
duration: 2,
|
|
emotion: emotionSlug ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase()) } : undefined,
|
|
difficulty: 'medium' as const
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch task:', err);
|
|
setError('Aufgabe konnte nicht geladen werden');
|
|
// Set fallback task
|
|
setTask({
|
|
id: taskIdNum,
|
|
title: `Unbekannte Aufgabe ${taskIdNum}`,
|
|
description: 'Stelle dich für das Foto auf und lächle in die Kamera.',
|
|
instructions: 'Positioniere dich gut und warte auf den Countdown.',
|
|
duration: 2,
|
|
emotion: emotionSlug ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase()) } : undefined,
|
|
difficulty: 'medium' as const
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
fetchTask();
|
|
}, [slug, taskId, emotionSlug]);
|
|
|
|
// Camera setup
|
|
useEffect(() => {
|
|
if (!slug || loading || !task) return;
|
|
|
|
const setupCamera = async () => {
|
|
try {
|
|
const constraints: MediaStreamConstraints = {
|
|
video: {
|
|
width: { ideal: 1280 },
|
|
height: { ideal: 720 },
|
|
facingMode: facingMode ? { ideal: facingMode } : undefined
|
|
}
|
|
};
|
|
|
|
console.log('Requesting camera with constraints:', constraints);
|
|
|
|
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
if (videoRef.current) {
|
|
videoRef.current.srcObject = newStream;
|
|
videoRef.current.play().catch(e => console.error('Video play error:', e));
|
|
// Set video dimensions after metadata is loaded
|
|
videoRef.current.onloadedmetadata = () => {
|
|
if (videoRef.current) {
|
|
videoRef.current.style.display = 'block';
|
|
}
|
|
};
|
|
}
|
|
setStream(newStream);
|
|
setError(null); // Clear any previous errors
|
|
} catch (err: any) {
|
|
console.error('Camera access error:', err.name, err.message);
|
|
|
|
let errorMessage = 'Kamera konnte nicht gestartet werden.';
|
|
|
|
switch (err.name) {
|
|
case 'NotAllowedError':
|
|
errorMessage = 'Kamera-Zugriff verweigert.\n\n' +
|
|
'• Chrome: Adressleiste klicken → Kamera-Symbol → "Zulassen"\n' +
|
|
'• Safari: Einstellungen → Website-Einstellungen → Kamera → "Erlauben"\n' +
|
|
'• Firefox: Adressleiste → Berechtigungen → Kamera → "Erlauben"\n\n' +
|
|
'Danach Seite neu laden.';
|
|
break;
|
|
case 'NotFoundError':
|
|
errorMessage = 'Keine Kamera gefunden. Bitte überprüfen Sie:\n' +
|
|
'• Ob eine Kamera am Gerät verfügbar ist\n' +
|
|
'• Ob andere Apps die Kamera verwenden\n' +
|
|
'• Gerätekonfiguration in den Browser-Einstellungen';
|
|
break;
|
|
case 'NotSupportedError':
|
|
errorMessage = 'Kamera nicht unterstützt. Bitte verwenden Sie:\n' +
|
|
'• Chrome, Firefox oder Safari (neueste Version)\n' +
|
|
'• HTTPS-Verbindung (nicht HTTP)';
|
|
break;
|
|
case 'OverconstrainedError':
|
|
errorMessage = 'Kamera-Einstellungen nicht verfügbar. Versuche mit Standard-Einstellungen...';
|
|
// Fallback to basic constraints
|
|
try {
|
|
const fallbackConstraints = { video: true };
|
|
const fallbackStream = await navigator.mediaDevices.getUserMedia(fallbackConstraints);
|
|
if (videoRef.current) {
|
|
videoRef.current.srcObject = fallbackStream;
|
|
videoRef.current.play();
|
|
}
|
|
setStream(fallbackStream);
|
|
setError(null);
|
|
return;
|
|
} catch (fallbackErr) {
|
|
console.error('Fallback camera failed:', fallbackErr);
|
|
}
|
|
break;
|
|
default:
|
|
errorMessage = `Kamera-Fehler (${err.name}): ${err.message}\n\nBitte versuchen Sie:\n• Seite neu laden\n• Browser neu starten\n• Anderen Browser verwenden`;
|
|
}
|
|
|
|
setError(errorMessage);
|
|
}
|
|
};
|
|
|
|
setupCamera();
|
|
|
|
return () => {
|
|
if (stream) {
|
|
stream.getTracks().forEach(track => track.stop());
|
|
setStream(null);
|
|
}
|
|
};
|
|
}, [slug, loading, task, facingMode]);
|
|
|
|
// Handle capture
|
|
const handleCapture = useCallback(async () => {
|
|
if (!videoRef.current || !canvasRef.current || !task) return;
|
|
|
|
setCapturing(true);
|
|
setIsPulsing(false);
|
|
|
|
// Start countdown
|
|
let count = 3;
|
|
setCountdown(count);
|
|
|
|
const countdownInterval = setInterval(() => {
|
|
count--;
|
|
setCountdown(count);
|
|
if (count <= 0) {
|
|
clearInterval(countdownInterval);
|
|
|
|
// Capture photo
|
|
const video = videoRef.current;
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const context = canvas.getContext('2d');
|
|
if (!context || !video) return;
|
|
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
context.drawImage(video, 0, 0);
|
|
|
|
// Convert to blob
|
|
canvas.toBlob(async (blob) => {
|
|
if (blob && task && slug) {
|
|
try {
|
|
// Show uploading state
|
|
setUploading(true);
|
|
setCapturing(false);
|
|
setCountdown(3);
|
|
setError(null);
|
|
|
|
// Use emotionSlug directly (backend expects string slug)
|
|
|
|
// Convert Blob to File with proper filename
|
|
const timestamp = Date.now();
|
|
const fileName = `photo-${timestamp}-${task.id}.jpg`;
|
|
const file = new File([blob], fileName, {
|
|
type: 'image/jpeg',
|
|
lastModified: timestamp
|
|
});
|
|
|
|
console.log('Uploading photo:', {
|
|
taskId: task.id,
|
|
emotionSlug,
|
|
fileName,
|
|
fileSize: file.size
|
|
});
|
|
|
|
// Upload the photo
|
|
const photoId = await uploadPhoto(slug, file, task.id, emotionSlug);
|
|
|
|
console.log('Upload successful, photo ID:', photoId);
|
|
|
|
// Navigate to gallery with success
|
|
navigate(`/e/${slug}/gallery?task=${task.id}&emotion=${emotionSlug}&uploaded=true`);
|
|
} catch (error: any) {
|
|
console.error('Upload failed:', error);
|
|
setError(`Upload fehlgeschlagen: ${error.message}\n\nFoto wurde erstellt, aber nicht hochgeladen.\nVersuchen Sie es erneut oder wählen Sie ein anderes Foto aus.`);
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}
|
|
}, 'image/jpeg', 0.8);
|
|
|
|
setCapturing(false);
|
|
setCountdown(3);
|
|
}
|
|
}, 1000);
|
|
|
|
// Start pulsing animation
|
|
setIsPulsing(true);
|
|
}, [task, emotionSlug, slug, navigate]);
|
|
|
|
// Switch camera
|
|
const switchCamera = () => {
|
|
setFacingMode(prev => prev === 'user' ? 'environment' : 'user');
|
|
};
|
|
|
|
// Toggle flash (for back camera only)
|
|
const toggleFlash = () => {
|
|
if (facingMode !== 'environment') return;
|
|
setFlashOn(prev => !prev);
|
|
// TODO: Implement actual flash control if possible
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<Page title="Kamera laden...">
|
|
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 ${
|
|
isDark ? 'text-white' : 'text-gray-900'
|
|
}`}>
|
|
<Camera className="h-12 w-12 animate-pulse mb-4 text-pink-500" />
|
|
<p className="text-sm">Kamera wird gestartet...</p>
|
|
</div>
|
|
<BottomNav />
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
if (error || !task) {
|
|
return (
|
|
<Page title="Fehler">
|
|
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 ${
|
|
isDark ? 'text-white' : 'text-gray-900'
|
|
}`}>
|
|
<Camera className="h-12 w-12 text-red-500 mb-4" />
|
|
<div className="text-center space-y-2">
|
|
<h2 className="text-xl font-semibold">Kamera nicht verfügbar</h2>
|
|
<p className="text-sm text-muted-foreground max-w-md">{error}</p>
|
|
<Button onClick={() => navigate(`/e/${slug}`)} variant="outline" className="mt-4">
|
|
Zurück zur Startseite
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<BottomNav />
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
if (uploading) {
|
|
return (
|
|
<Page title="Foto wird hochgeladen...">
|
|
<div className={`flex flex-col items-center justify-center min-h-screen p-4 ${
|
|
isDark ? 'text-white' : 'text-gray-900'
|
|
}`}>
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-pink-500 mb-4"></div>
|
|
<h2 className="text-xl font-semibold mb-2">Foto wird hochgeladen</h2>
|
|
<p className="text-sm text-center text-muted-foreground">Bitte warten... Dies kann einen Moment dauern.</p>
|
|
<Button
|
|
onClick={() => navigate(`/e/${slug}/gallery`)}
|
|
variant="outline"
|
|
className="mt-6"
|
|
disabled={true}
|
|
>
|
|
Zur Galerie
|
|
</Button>
|
|
</div>
|
|
<BottomNav />
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
const difficultyColor = task.difficulty === 'easy' ? 'text-green-400' :
|
|
task.difficulty === 'medium' ? 'text-yellow-400' : 'text-red-400';
|
|
|
|
return (
|
|
<Page title={task.title}>
|
|
<div className={`min-h-screen flex flex-col ${
|
|
isDark ? 'bg-gray-900 text-white' : 'bg-black text-white'
|
|
}`}>
|
|
{/* Camera Preview Container */}
|
|
<div className="flex-1 relative overflow-hidden">
|
|
{/* Video Background */}
|
|
<video
|
|
ref={videoRef}
|
|
className="absolute inset-0 w-full h-full object-cover"
|
|
playsInline
|
|
muted
|
|
/>
|
|
|
|
{/* Task Info Overlay */}
|
|
<div className="absolute top-4 left-4 right-4 z-10">
|
|
<div className="space-y-2 bg-black/40 backdrop-blur-sm rounded-xl p-4 border border-white/20">
|
|
<h1 className="text-xl font-bold">{task.title}</h1>
|
|
<p className="text-sm leading-relaxed opacity-90">{task.description}</p>
|
|
{task.instructions && (
|
|
<div className="text-xs italic opacity-80 mt-2 pt-2 border-t border-white/20">
|
|
💡 {task.instructions}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between pt-2">
|
|
<span className={`text-xs font-medium ${difficultyColor}`}>
|
|
Schwierigkeit: {task.difficulty}
|
|
</span>
|
|
{emotionSlug && (
|
|
<span className="text-xs opacity-80">
|
|
Stimmung: {task.emotion?.name || emotionSlug}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Camera Controls */}
|
|
<div className="absolute bottom-20 left-4 right-4 z-10">
|
|
<div className="flex justify-between items-center mb-6">
|
|
{/* Flash Button */}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={toggleFlash}
|
|
disabled={facingMode === 'user'}
|
|
className="h-12 w-12 rounded-full bg-black/40 backdrop-blur-sm border border-white/20"
|
|
>
|
|
{flashOn ? <Zap className="h-6 w-6 text-yellow-400" /> : <ZapOff className="h-6 w-6" />}
|
|
</Button>
|
|
|
|
{/* Capture Button */}
|
|
<div className="relative">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleCapture}
|
|
disabled={capturing}
|
|
className={`
|
|
h-20 w-20 rounded-full bg-white/20 backdrop-blur-sm border-4 border-white/30
|
|
${isPulsing ? 'animate-pulse-ring' : ''}
|
|
disabled:opacity-50 disabled:cursor-not-allowed
|
|
`}
|
|
>
|
|
<div className="relative">
|
|
<Camera className="h-8 w-8" />
|
|
{capturing && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="text-lg font-bold text-white">{countdown}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Button>
|
|
{/* Pulsing Ring Animation */}
|
|
{isPulsing && (
|
|
<div className="absolute inset-0 rounded-full h-20 w-20 border-4 border-pink-400/50 animate-ping" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Switch Camera Button */}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={switchCamera}
|
|
className="h-12 w-12 rounded-full bg-black/40 backdrop-blur-sm border border-white/20"
|
|
>
|
|
<RotateCcw className="h-6 w-6" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hidden Canvas for Capture */}
|
|
<canvas ref={canvasRef} className="hidden" />
|
|
</div>
|
|
|
|
<BottomNav />
|
|
</div>
|
|
|
|
<style>{`
|
|
@keyframes pulse-ring {
|
|
0% {
|
|
transform: scale(1);
|
|
opacity: 1;
|
|
}
|
|
100% {
|
|
transform: scale(1.5);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
.animate-ping {
|
|
animation: pulse-ring 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
|
}
|
|
`}</style>
|
|
</Page>
|
|
);
|
|
}
|