fixed like action, better dark mode, bottom navigation working, added taskcollection
This commit is contained in:
@@ -66,21 +66,77 @@ export default function GalleryPage() {
|
||||
)}
|
||||
{loading && <p>Lade…</p>}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{list.map((p) => (
|
||||
<Card key={p.id} className="relative overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<Link to={`../photo/${p.id}`} state={{ photo: p }}>
|
||||
<img src={p.thumbnail_path || p.file_path} alt="Foto" className="aspect-square w-full object-cover" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
<div className="absolute bottom-1 right-1 flex items-center gap-1 rounded-full bg-black/50 px-2 py-1 text-white">
|
||||
<button onClick={() => onLike(p.id)} className={`inline-flex items-center ${liked.has(p.id) ? 'text-red-400' : ''}`} aria-label="Like">
|
||||
<Heart className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-xs">{counts[p.id] ?? p.likes_count ?? 0}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{list.map((p: any) => {
|
||||
// Debug: Log image URLs
|
||||
const imgSrc = p.thumbnail_path || p.file_path;
|
||||
|
||||
// Normalize image URL
|
||||
let imageUrl = imgSrc;
|
||||
let cleanPath = '';
|
||||
|
||||
if (imageUrl) {
|
||||
// Remove leading/trailing slashes for processing
|
||||
cleanPath = imageUrl.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
// Check if path already contains storage prefix
|
||||
if (cleanPath.startsWith('storage/')) {
|
||||
// Already has storage prefix, just ensure it starts with /
|
||||
imageUrl = `/${cleanPath}`;
|
||||
} else {
|
||||
// Add storage prefix
|
||||
imageUrl = `/storage/${cleanPath}`;
|
||||
}
|
||||
|
||||
// Remove double slashes
|
||||
imageUrl = imageUrl.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
// Extended debug logging
|
||||
console.log(`Photo ${p.id} URL processing:`, {
|
||||
id: p.id,
|
||||
original: imgSrc,
|
||||
thumbnail_path: p.thumbnail_path,
|
||||
file_path: p.file_path,
|
||||
cleanPath,
|
||||
finalUrl: imageUrl,
|
||||
isHttp: imageUrl?.startsWith('http'),
|
||||
startsWithStorage: imageUrl?.startsWith('/storage/')
|
||||
});
|
||||
|
||||
return (
|
||||
<Card key={p.id} className="relative overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<Link to={`../photo/${p.id}`} state={{ photo: p }}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`}
|
||||
className="aspect-square w-full object-cover bg-gray-200"
|
||||
onError={(e) => {
|
||||
console.error(`❌ Failed to load image ${p.id}:`, imageUrl);
|
||||
console.error('Error details:', e);
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
|
||||
}}
|
||||
onLoad={() => console.log(`✅ Successfully loaded image ${p.id}:`, imageUrl)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Link>
|
||||
</CardContent>
|
||||
|
||||
{p.task_title && (
|
||||
<div className="px-2 pb-2 text-center">
|
||||
<p className="text-xs text-gray-700 truncate bg-white/80 py-1 rounded-sm">{p.task_title}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-1 right-1 flex items-center gap-1 rounded-full bg-black/50 px-2 py-1 text-white">
|
||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onLike(p.id); }} className={`inline-flex items-center ${liked.has(p.id) ? 'text-red-400' : ''}`} aria-label="Like">
|
||||
<Heart className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-xs">{counts[p.id] ?? (p.likes_count || 0)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -2,33 +2,36 @@ import React from 'react';
|
||||
import { Page } from './_util';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { usePollStats } from '../polling/usePollStats';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Header from '../components/Header';
|
||||
import EmotionPicker from '../components/EmotionPicker';
|
||||
import GalleryPreview from '../components/GalleryPreview';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
|
||||
export default function HomePage() {
|
||||
const { slug } = useParams();
|
||||
const stats = usePollStats(slug!);
|
||||
return (
|
||||
<Page title={`Event: ${slug}`}>
|
||||
<Card>
|
||||
<CardContent className="p-3 text-sm">
|
||||
{stats.loading ? 'Lade…' : (
|
||||
<span>
|
||||
<span className="font-medium">{stats.onlineGuests}</span> Gäste online · ✅{' '}
|
||||
<span className="font-medium">{stats.tasksSolved}</span> Aufgaben gelöst
|
||||
<Header slug={slug!} title={`Event: ${slug}`} />
|
||||
<div className="px-4 py-6 pb-20 space-y-6"> {/* Consistent spacing */}
|
||||
{/* Prominent Draw Task Button */}
|
||||
<Link to="tasks">
|
||||
<Button className="w-full bg-gradient-to-r from-pink-500 to-pink-600 hover:from-pink-600 hover:to-pink-700 text-white py-4 rounded-xl text-base font-semibold mb-6 shadow-lg hover:shadow-xl transition-all duration-200">
|
||||
<span className="flex items-center gap-2">
|
||||
🎲 Aufgabe ziehen
|
||||
</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="h-3" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link to="tasks"><Button variant="secondary">Aufgabe ziehen</Button></Link>
|
||||
<Link to="tasks"><Button variant="secondary">Wie fühlst du dich?</Button></Link>
|
||||
<Link to="upload"><Button>Einfach ein Foto machen</Button></Link>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* How do you feel? Section */}
|
||||
<EmotionPicker />
|
||||
|
||||
<GalleryPreview slug={slug!} />
|
||||
</div>
|
||||
<div className="h-4" />
|
||||
<GalleryPreview slug={slug!} />
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<BottomNav />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,58 +5,190 @@ import { Button } from '@/components/ui/button';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { likePhoto } from '../services/photosApi';
|
||||
|
||||
type Photo = { id: number; file_path?: string; thumbnail_path?: string; likes_count?: number; created_at?: string };
|
||||
type Photo = {
|
||||
id: number;
|
||||
file_path?: string;
|
||||
thumbnail_path?: string;
|
||||
likes_count?: number;
|
||||
created_at?: string;
|
||||
task_id?: number
|
||||
};
|
||||
|
||||
type Task = { id: number; title: string };
|
||||
|
||||
export default function PhotoLightbox() {
|
||||
const nav = useNavigate();
|
||||
const { state } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { photoId } = useParams();
|
||||
const [photo, setPhoto] = React.useState<Photo | null>((state as any)?.photo ?? null);
|
||||
|
||||
|
||||
const [photo, setPhoto] = React.useState<Photo | null>(null);
|
||||
const [task, setTask] = React.useState<Task | null>(null);
|
||||
const [taskLoading, setTaskLoading] = React.useState(false);
|
||||
const [likes, setLikes] = React.useState<number | null>(null);
|
||||
const [liked, setLiked] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (photo) return;
|
||||
(async () => {
|
||||
const res = await fetch(`/api/v1/photos/${photoId}`);
|
||||
if (res.ok) setPhoto(await res.json());
|
||||
})();
|
||||
}, [photo, photoId]);
|
||||
// Extract event slug from URL path
|
||||
const getEventSlug = () => {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/^\/e\/([^\/]+)\/photo\/[^\/]+$/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const slug = getEventSlug();
|
||||
|
||||
// Load photo if not passed via state
|
||||
React.useEffect(() => {
|
||||
if (photo && likes === null) setLikes(photo.likes_count ?? 0);
|
||||
}, [photo, likes]);
|
||||
const statePhoto = (location.state as any)?.photo;
|
||||
if (statePhoto) {
|
||||
setPhoto(statePhoto);
|
||||
setLikes(statePhoto.likes_count ?? 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!photoId) return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/photos/${photoId}`);
|
||||
if (res.ok) {
|
||||
const photoData = await res.json();
|
||||
setPhoto(photoData);
|
||||
setLikes(photoData.likes_count ?? 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load photo:', error);
|
||||
}
|
||||
})();
|
||||
}, [photoId, location.state]);
|
||||
|
||||
// Load task info if photo has task_id and slug is available
|
||||
React.useEffect(() => {
|
||||
if (!photo?.task_id || !slug) {
|
||||
setTask(null);
|
||||
setTaskLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = photo.task_id;
|
||||
|
||||
(async () => {
|
||||
setTaskLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/events/${slug}/tasks`);
|
||||
if (res.ok) {
|
||||
const tasks = await res.json();
|
||||
const foundTask = tasks.find((t: any) => t.id === taskId);
|
||||
if (foundTask) {
|
||||
setTask({
|
||||
id: foundTask.id,
|
||||
title: foundTask.title || `Aufgabe ${taskId}`
|
||||
});
|
||||
} else {
|
||||
setTask({
|
||||
id: taskId,
|
||||
title: `Unbekannte Aufgabe ${taskId}`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setTask({
|
||||
id: taskId,
|
||||
title: `Unbekannte Aufgabe ${taskId}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load task:', error);
|
||||
setTask({
|
||||
id: taskId,
|
||||
title: `Unbekannte Aufgabe ${taskId}`
|
||||
});
|
||||
} finally {
|
||||
setTaskLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [photo?.task_id, slug]);
|
||||
|
||||
async function onLike() {
|
||||
if (liked || !photo) return;
|
||||
setLiked(true);
|
||||
const c = await likePhoto(photo.id);
|
||||
setLikes(c);
|
||||
try {
|
||||
const count = await likePhoto(photo.id);
|
||||
setLikes(count);
|
||||
} catch (error) {
|
||||
console.error('Like failed:', error);
|
||||
setLiked(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open) nav(-1);
|
||||
if (!open) navigate(-1);
|
||||
}
|
||||
|
||||
if (!photo && !photoId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onOpenChange}>
|
||||
<Dialog open={true} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl border-0 bg-black p-0 text-white">
|
||||
{/* Header with controls */}
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onLike} disabled={liked}>
|
||||
<Heart className="mr-1 h-4 w-4" /> {likes ?? 0}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onLike}
|
||||
disabled={liked || !photo}
|
||||
>
|
||||
<Heart className={`mr-1 h-4 w-4 ${liked ? 'fill-red-400 text-red-400' : ''}`} />
|
||||
{likes ?? 0}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => nav(-1)}>Schließen</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate(-1)}>
|
||||
Schließen
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
|
||||
{/* Task Info Overlay */}
|
||||
{task && (
|
||||
<div className="absolute top-4 left-4 right-4 z-20 bg-black/60 backdrop-blur-sm rounded-xl p-3 border border-white/20 max-w-md">
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold mb-1 text-white">Task: {task.title}</div>
|
||||
{taskLoading && (
|
||||
<div className="text-xs opacity-70 text-gray-300">Lade Aufgabe...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photo Display */}
|
||||
<div className="flex items-center justify-center min-h-[60vh] p-4">
|
||||
{photo ? (
|
||||
<img src={photo.file_path || photo.thumbnail_path} alt="Foto" className="max-h-[80vh] w-auto" />
|
||||
<img
|
||||
src={photo.file_path || photo.thumbnail_path}
|
||||
alt="Foto"
|
||||
className="max-h-[80vh] max-w-full object-contain"
|
||||
onError={(e) => {
|
||||
console.error('Image load error:', e);
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-6">Lade…</div>
|
||||
<div className="p-6 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<div>Lade Foto...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading state for task */}
|
||||
{taskLoading && !task && (
|
||||
<div className="absolute top-4 left-4 right-4 z-20 bg-black/60 backdrop-blur-sm rounded-xl p-3 border border-white/20 max-w-md">
|
||||
<div className="text-sm text-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mx-auto mb-1"></div>
|
||||
<div className="text-xs opacity-70">Lade Aufgabe...</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,264 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } 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 { Clock, RefreshCw, Smile } from 'lucide-react';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { EventData } from '../services/eventApi';
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
duration: number; // in minutes
|
||||
emotion?: {
|
||||
slug: string;
|
||||
name: string;
|
||||
};
|
||||
is_completed: boolean;
|
||||
}
|
||||
|
||||
export default function TaskPickerPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
// emotionSlug = searchParams.get('emotion'); // Temporär deaktiviert, da API-Filter nicht verfügbar
|
||||
const navigate = useNavigate();
|
||||
const { appearance } = useAppearance();
|
||||
const isDark = appearance === 'dark';
|
||||
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [currentTask, setCurrentTask] = useState<Task | null>(null);
|
||||
const [timeLeft, setTimeLeft] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Timer state
|
||||
useEffect(() => {
|
||||
if (!currentTask) return;
|
||||
|
||||
const durationMs = currentTask.duration * 60 * 1000;
|
||||
setTimeLeft(durationMs / 1000);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(interval);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentTask]);
|
||||
|
||||
// Load tasks
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
|
||||
async function fetchTasks() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = `/api/v1/events/${slug}/tasks`;
|
||||
console.log('Fetching tasks from:', url); // Debug
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Tasks konnten nicht geladen werden');
|
||||
|
||||
const data = await response.json();
|
||||
setTasks(Array.isArray(data) ? data : []);
|
||||
|
||||
console.log('Loaded tasks:', data); // Debug
|
||||
|
||||
// Select random task
|
||||
if (data.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * data.length);
|
||||
setCurrentTask(data[randomIndex]);
|
||||
console.log('Selected random task:', data[randomIndex]); // Debug
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fetch tasks error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchTasks();
|
||||
}, [slug]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleNewTask = () => {
|
||||
if (tasks.length === 0) return;
|
||||
const randomIndex = Math.floor(Math.random() * tasks.length);
|
||||
setCurrentTask(tasks[randomIndex]);
|
||||
setTimeLeft(tasks[randomIndex].duration * 60);
|
||||
};
|
||||
|
||||
const handleStartTask = () => {
|
||||
if (!currentTask) return;
|
||||
// Navigate to upload with task context
|
||||
navigate(`/e/${slug}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
|
||||
};
|
||||
|
||||
const handleChangeMood = () => {
|
||||
navigate(`/e/${slug}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Page title="Aufgabe laden...">
|
||||
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 ${
|
||||
isDark ? 'text-white' : 'text-gray-900'
|
||||
}`}>
|
||||
<RefreshCw className="h-8 w-8 animate-spin mb-4" />
|
||||
<p className="text-sm">Lade Aufgabe...</p>
|
||||
</div>
|
||||
<BottomNav />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !currentTask) {
|
||||
return (
|
||||
<Page title="Keine Aufgaben verfügbar">
|
||||
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 space-y-4 ${
|
||||
isDark ? 'text-white' : 'text-gray-900'
|
||||
}`}>
|
||||
<Smile className="h-12 w-12 text-pink-500" />
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold mb-2">Keine passende Aufgabe gefunden</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{error || 'Für deine Stimmung gibt es derzeit keine Aufgaben. Versuche eine andere Stimmung oder warte auf neue Inhalte.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleChangeMood}
|
||||
className="w-full max-w-sm"
|
||||
>
|
||||
Andere Stimmung wählen
|
||||
</Button>
|
||||
</div>
|
||||
<BottomNav />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title="Stimmung wählen / Aufgabe ziehen">
|
||||
<p>Stubs for emotion grid and random task.</p>
|
||||
<Page title={currentTask.title}>
|
||||
<div className={`min-h-screen flex flex-col ${
|
||||
isDark ? 'bg-gray-900 text-white' : 'bg-white text-gray-900'
|
||||
}`}>
|
||||
{/* Task Header with Selfie Overlay */}
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="relative">
|
||||
{/* Selfie Placeholder */}
|
||||
<div className={`w-full aspect-square rounded-2xl bg-gradient-to-br ${
|
||||
isDark
|
||||
? 'from-gray-800 to-gray-700 shadow-2xl'
|
||||
: 'from-pink-50 to-pink-100 shadow-lg'
|
||||
} flex items-center justify-center`}>
|
||||
<div className="text-center space-y-2">
|
||||
<div className={`w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-2 ${
|
||||
isDark ? 'text-white' : 'text-gray-600'
|
||||
}`}>
|
||||
📸
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${
|
||||
isDark ? 'text-gray-300' : 'text-gray-600'
|
||||
}`}>
|
||||
Selfie-Vorschau
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
||||
timeLeft > 60
|
||||
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||
: timeLeft > 30
|
||||
? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
|
||||
: 'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
} border`}>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTime(timeLeft)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Description Overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-transparent to-transparent p-4 rounded-b-2xl">
|
||||
<div className="space-y-2">
|
||||
<h1 className={`text-xl font-bold ${
|
||||
isDark ? 'text-gray-100' : 'text-white'
|
||||
}`}>
|
||||
{currentTask.title}
|
||||
</h1>
|
||||
<p className={`text-sm leading-relaxed ${
|
||||
isDark ? 'text-gray-200' : 'text-gray-100'
|
||||
}`}>
|
||||
{currentTask.description}
|
||||
</p>
|
||||
{currentTask.instructions && (
|
||||
<div className={`p-2 rounded-lg ${
|
||||
isDark
|
||||
? 'bg-gray-700/80 text-gray-100'
|
||||
: 'bg-gray-800/80 text-white border border-gray-600/50'
|
||||
}`}>
|
||||
<p className="text-xs italic">💡 {currentTask.instructions}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
<Button
|
||||
onClick={handleStartTask}
|
||||
className="w-full h-14 bg-gradient-to-r from-pink-500 to-pink-600 hover:from-pink-600 hover:to-pink-700 text-white rounded-xl text-base font-semibold shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
📸 Los geht's
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleNewTask}
|
||||
className="flex-1 h-12 border-gray-300 dark:border-gray-600 text-sm rounded-xl transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Neue Aufgabe
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleChangeMood}
|
||||
className="flex-1 h-12 text-sm rounded-xl transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<Smile className="h-4 w-4 mr-2" />
|
||||
Andere Stimmung
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<BottomNav />
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +1,471 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Page } from './_util';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
import { compressPhoto, formatBytes } from '../lib/image';
|
||||
import { useUploadQueue } from '../queue/hooks';
|
||||
import React from 'react';
|
||||
import { useAppearance } from '../../hooks/use-appearance';
|
||||
import { Camera, RotateCcw, Zap, ZapOff } from 'lucide-react';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
import { uploadPhoto } from '../services/photosApi';
|
||||
|
||||
type Item = { file: File; out?: File; progress: number; done?: boolean; error?: string; id?: number };
|
||||
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();
|
||||
const [items, setItems] = React.useState<Item[]>([]);
|
||||
const queue = useUploadQueue();
|
||||
const [progressMap, setProgressMap] = React.useState<Record<number, number>>({});
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { appearance } = useAppearance();
|
||||
const isDark = appearance === 'dark';
|
||||
|
||||
React.useEffect(() => {
|
||||
const onProg = (e: any) => {
|
||||
const { id, progress } = e.detail || {};
|
||||
if (typeof id === 'number') setProgressMap((m) => ({ ...m, [id]: progress }));
|
||||
};
|
||||
window.addEventListener('queue-progress', onProg);
|
||||
return () => window.removeEventListener('queue-progress', onProg);
|
||||
}, []);
|
||||
// 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);
|
||||
|
||||
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files ?? []).slice(0, 10);
|
||||
const results: Item[] = [];
|
||||
for (const f of files) {
|
||||
// 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 {
|
||||
const out = await compressPhoto(f, { targetBytes: 1_500_000, maxEdge: 2560, qualityStart: 0.85 });
|
||||
results.push({ file: f, out, progress: 0 });
|
||||
} catch (err: any) {
|
||||
results.push({ file: f, progress: 0, error: err?.message || 'Komprimierung fehlgeschlagen' });
|
||||
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);
|
||||
}
|
||||
}
|
||||
setItems(results);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
async function startUpload() {
|
||||
// Enqueue items for offline-friendly processing
|
||||
for (const it of items) {
|
||||
await queue.add({ slug: slug!, fileName: it.out?.name ?? it.file.name, blob: it.out ?? it.file });
|
||||
}
|
||||
setItems([]);
|
||||
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="Foto aufnehmen/hochladen">
|
||||
<Input type="file" accept="image/*" multiple capture="environment" onChange={onPick} />
|
||||
<div className="h-3" />
|
||||
<Button onClick={startUpload} disabled={items.length === 0}>Hochladen</Button>
|
||||
<div className="mt-4 space-y-2">
|
||||
{items.map((it, i) => (
|
||||
<div key={i} className="rounded border p-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="truncate">{it.file.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatBytes(it.out?.size ?? it.file.size)}
|
||||
</div>
|
||||
</div>
|
||||
{it.done && <div className="text-xs text-muted-foreground">Fertig</div>}
|
||||
{it.error && <div className="text-xs text-red-500">{it.error}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 text-sm font-medium">Warteschlange</div>
|
||||
{queue.loading ? (
|
||||
<div className="text-sm text-muted-foreground">Lade…</div>
|
||||
) : queue.items.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Keine offenen Uploads.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{queue.items.map((q) => (
|
||||
<div key={q.id} className="rounded border p-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="truncate">{q.fileName}</div>
|
||||
<div className="text-xs text-muted-foreground">{q.status}{q.status==='uploading' && typeof q.id==='number' ? ` • ${progressMap[q.id] ?? 0}%` : ''}</div>
|
||||
<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>
|
||||
{q.status === 'uploading' && typeof q.id==='number' && (
|
||||
<div className="mt-2 h-2 w-full rounded bg-gray-200 dark:bg-gray-700">
|
||||
<div className="h-2 rounded bg-emerald-500" style={{ width: `${progressMap[q.id] ?? 0}%` }} />
|
||||
</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 className="flex gap-2">
|
||||
<Button variant="secondary" onClick={queue.retryAll}>Erneut versuchen</Button>
|
||||
<Button variant="secondary" onClick={queue.clearFinished}>Erledigte entfernen</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user