From 216ee063ffefd0ca2afcbc7d593503f5516073c8 Mon Sep 17 00:00:00 2001 From: SEB Fotografie - soeren Date: Sat, 13 Sep 2025 00:43:53 +0200 Subject: [PATCH] fixed like action, better dark mode, bottom navigation working, added taskcollection --- .../Controllers/Api/EventPublicController.php | 270 ++++++++- app/Http/Middleware/VerifyCsrfToken.php | 18 + app/Models/TaskCollection.php | 67 +++ bootstrap/app.php | 1 + ...200_create_event_task_collection_table.php | 35 ++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/TaskCollectionsSeeder.php | 90 +++ resources/js/guest/components/BottomNav.tsx | 88 ++- .../js/guest/components/EmotionPicker.tsx | 149 +++++ .../js/guest/components/GalleryPreview.tsx | 101 +++- resources/js/guest/components/Header.tsx | 92 ++- resources/js/guest/hooks/useEventData.ts | 40 ++ resources/js/guest/pages/GalleryPage.tsx | 86 ++- resources/js/guest/pages/HomePage.tsx | 37 +- resources/js/guest/pages/PhotoLightbox.tsx | 180 +++++- resources/js/guest/pages/TaskPickerPage.tsx | 261 ++++++++- resources/js/guest/pages/UploadPage.tsx | 522 +++++++++++++++--- .../js/guest/polling/usePollGalleryDelta.ts | 75 ++- resources/js/guest/router.tsx | 2 +- resources/js/guest/services/eventApi.ts | 42 ++ resources/js/guest/services/photosApi.ts | 93 +++- resources/views/guest.blade.php | 1 + routes/api.php | 20 + routes/web.php | 7 - 24 files changed, 2070 insertions(+), 208 deletions(-) create mode 100644 app/Http/Middleware/VerifyCsrfToken.php create mode 100644 app/Models/TaskCollection.php create mode 100644 database/migrations/2025_09_12_095200_create_event_task_collection_table.php create mode 100644 database/seeders/TaskCollectionsSeeder.php create mode 100644 resources/js/guest/components/EmotionPicker.tsx create mode 100644 resources/js/guest/hooks/useEventData.ts create mode 100644 resources/js/guest/services/eventApi.ts create mode 100644 routes/api.php diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 7d268e6..6b39768 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -13,6 +13,14 @@ use App\Support\ImageHelper; class EventPublicController extends BaseController { + private function getLocalized($value, $locale, $default = '') { + if (is_string($value) && json_decode($value) !== null) { + $data = json_decode($value, true); + return $data[$locale] ?? $data['de'] ?? $default; + } + return $value ?: $default; + } + private function toPublicUrl(?string $path): ?string { if (! $path) return null; @@ -46,13 +54,35 @@ class EventPublicController extends BaseController return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found']], 404); } + $locale = request()->query('locale', 'de'); + $nameData = json_decode($event->name, true); + $localizedName = $nameData[$locale] ?? $nameData['de'] ?? $event->name; + + // Get event type for icon + $eventType = DB::table('events') + ->join('event_types', 'events.event_type_id', '=', 'event_types.id') + ->where('events.id', $event->id) + ->first(['event_types.slug as type_slug', 'event_types.name as type_name']); + + $locale = request()->query('locale', 'de'); + $eventTypeData = $eventType ? [ + 'slug' => $eventType->type_slug, + 'name' => $this->getLocalized($eventType->type_name, $locale, 'Event'), + 'icon' => $eventType->type_slug === 'wedding' ? '❤️' : '👥' + ] : [ + 'slug' => 'general', + 'name' => $this->getLocalized('Event', $locale, 'Event'), + 'icon' => '👥' + ]; + return response()->json([ 'id' => $event->id, 'slug' => $event->slug, - 'name' => $event->name, + 'name' => $localizedName, 'default_locale' => $event->default_locale, 'created_at' => $event->created_at, 'updated_at' => $event->updated_at, + 'type' => $eventTypeData, ])->header('Cache-Control', 'no-store'); } @@ -95,6 +125,139 @@ class EventPublicController extends BaseController ->header('ETag', $etag); } + public function emotions(string $slug) + { + $event = DB::table('events')->where('slug', $slug)->first(['id']); + if (! $event) { + return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found']], 404); + } + + $rows = DB::table('emotions') + ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id') + ->join('event_types', 'emotion_event_type.event_type_id', '=', 'event_types.id') + ->join('events', 'events.event_type_id', '=', 'event_types.id') + ->where('events.id', $event->id) + ->select([ + 'emotions.id', + 'emotions.name', + 'emotions.icon as emoji', + 'emotions.description' + ]) + ->orderBy('emotions.sort_order') + ->get(); + + $payload = $rows->map(function ($r) { + $locale = request()->query('locale', 'de'); + $nameData = json_decode($r->name, true); + $name = $nameData[$locale] ?? $nameData['de'] ?? $r->name; + + $descriptionData = json_decode($r->description, true); + $description = $descriptionData ? ($descriptionData[$locale] ?? $descriptionData['de'] ?? '') : ''; + + return [ + 'id' => (int) $r->id, + 'slug' => 'emotion-' . $r->id, // Generate slug from ID + 'name' => $name, + 'emoji' => $r->emoji, + 'description' => $description, + ]; + }); + + $etag = sha1($payload->toJson()); + $reqEtag = request()->headers->get('If-None-Match'); + if ($reqEtag && $reqEtag === $etag) { + return response('', 304); + } + + return response()->json($payload) + ->header('Cache-Control', 'public, max-age=300') + ->header('ETag', $etag); + } + + public function tasks(string $slug, Request $request) + { + $event = DB::table('events')->where('slug', $slug)->first(['id']); + if (! $event) { + return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found']], 404); + } + + $query = DB::table('tasks') + ->join('task_collection_task', 'tasks.id', '=', 'task_collection_task.task_id') + ->join('event_task_collection', 'task_collection_task.task_collection_id', '=', 'event_task_collection.task_collection_id') + ->where('event_task_collection.event_id', $event->id) + ->select([ + 'tasks.id', + 'tasks.title', + 'tasks.description', + 'tasks.example_text as instructions', + 'tasks.emotion_id', + 'tasks.sort_order' + ]) + ->orderBy('event_task_collection.sort_order') + ->orderBy('tasks.sort_order') + ->limit(20); + + $rows = $query->get(); + + $payload = $rows->map(function ($r) { + // Handle JSON fields for multilingual content + $getLocalized = function ($field, $default = '') use ($r) { + $locale = request()->query('locale', 'de'); + $value = $r->$field; + if (is_string($value) && json_decode($value) !== null) { + $data = json_decode($value, true); + return $data[$locale] ?? $data['de'] ?? $default; + } + return $value ?: $default; + }; + + // Get emotion info if emotion_id exists + $emotion = null; + if ($r->emotion_id) { + $emotionRow = DB::table('emotions')->where('id', $r->emotion_id)->first(['name']); + if ($emotionRow && isset($emotionRow->name)) { + $locale = request()->query('locale', 'de'); + $value = $emotionRow->name; + if (is_string($value) && json_decode($value) !== null) { + $data = json_decode($value, true); + $emotionName = $data[$locale] ?? $data['de'] ?? 'Unbekannte Emotion'; + } else { + $emotionName = $value ?: 'Unbekannte Emotion'; + } + $emotion = [ + 'slug' => 'emotion-' . $r->emotion_id, // Generate slug from ID + 'name' => $emotionName, + ]; + } + } + + return [ + 'id' => (int) $r->id, + 'title' => $getLocalized('title', 'Unbenannte Aufgabe'), + 'description' => $getLocalized('description', ''), + 'instructions' => $getLocalized('instructions', ''), + 'duration' => 3, // Default 3 minutes (no duration field in DB) + 'is_completed' => false, // Default false (no is_completed field in DB) + 'emotion' => $emotion, + ]; + }); + + // If no tasks found, return empty array instead of 404 + if ($payload->isEmpty()) { + $payload = collect([]); + } + + $etag = sha1($payload->toJson()); + $reqEtag = request()->headers->get('If-None-Match'); + if ($reqEtag && $reqEtag === $etag) { + return response('', 304); + } + + return response()->json($payload) + ->header('Cache-Control', 'public, max-age=300') + ->header('ETag', $etag); + } + public function photos(Request $request, string $slug) { $event = DB::table('events')->where('slug', $slug)->first(['id']); @@ -103,20 +266,46 @@ class EventPublicController extends BaseController } $eventId = $event->id; + $deviceId = (string) $request->header('X-Device-Id', 'anon'); + $filter = $request->query('filter'); + $since = $request->query('since'); $query = DB::table('photos') - ->select(['id', 'file_path', 'thumbnail_path', 'likes_count', 'emotion_id', 'task_id', 'created_at']) - ->where('event_id', $eventId) - ->orderByDesc('created_at') + ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') + ->select([ + 'photos.id', + 'photos.file_path', + 'photos.thumbnail_path', + 'photos.likes_count', + 'photos.emotion_id', + 'photos.task_id', + 'photos.created_at', + 'photos.guest_name', + 'tasks.title as task_title' + ]) + ->where('photos.event_id', $eventId) + ->orderByDesc('photos.created_at') ->limit(60); + // MyPhotos filter + if ($filter === 'myphotos' && $deviceId !== 'anon') { + $query->where('guest_name', $deviceId); + } + if ($since) { $query->where('created_at', '>', $since); } + $locale = request()->query('locale', 'de'); - $rows = $query->get()->map(function ($r) { + $rows = $query->get()->map(function ($r) use ($locale) { $r->file_path = $this->toPublicUrl((string)($r->file_path ?? '')); $r->thumbnail_path = $this->toPublicUrl((string)($r->thumbnail_path ?? '')); + + // Localize task title if present + if ($r->task_title) { + $r->task_title = $this->getLocalized($r->task_title, $locale, 'Unbenannte Aufgabe'); + } + return $r; }); $latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at'); @@ -124,7 +313,7 @@ class EventPublicController extends BaseController 'data' => $rows, 'latest_photo_at' => $latestPhotoAt, ]; - $etag = sha1(json_encode([$since, $latestPhotoAt])); + $etag = sha1(json_encode([$since, $filter, $deviceId, $latestPhotoAt])); $reqEtag = request()->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304); @@ -138,14 +327,32 @@ class EventPublicController extends BaseController public function photo(int $id) { $row = DB::table('photos') - ->select(['id', 'event_id', 'file_path', 'thumbnail_path', 'likes_count', 'emotion_id', 'task_id', 'created_at']) - ->where('id', $id) + ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') + ->select([ + 'photos.id', + 'photos.event_id', + 'photos.file_path', + 'photos.thumbnail_path', + 'photos.likes_count', + 'photos.emotion_id', + 'photos.task_id', + 'photos.created_at', + 'tasks.title as task_title' + ]) + ->where('photos.id', $id) ->first(); if (! $row) { return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found']], 404); } $row->file_path = $this->toPublicUrl((string)($row->file_path ?? '')); $row->thumbnail_path = $this->toPublicUrl((string)($row->thumbnail_path ?? '')); + + // Localize task title if present + $locale = request()->query('locale', 'de'); + if ($row->task_title) { + $row->task_title = $this->getLocalized($row->task_title, $locale, 'Unbenannte Aufgabe'); + } + return response()->json($row)->header('Cache-Control', 'no-store'); } @@ -211,6 +418,7 @@ class EventPublicController extends BaseController $validated = $request->validate([ 'photo' => ['required', 'image', 'max:6144'], // 6 MB 'emotion_id' => ['nullable', 'integer'], + 'emotion_slug' => ['nullable', 'string'], 'task_id' => ['nullable', 'integer'], 'guest_name' => ['nullable', 'string', 'max:255'], ]); @@ -227,12 +435,14 @@ class EventPublicController extends BaseController $id = DB::table('photos')->insertGetId([ 'event_id' => $event->id, - 'emotion_id' => $validated['emotion_id'] ?? null, 'task_id' => $validated['task_id'] ?? null, 'guest_name' => $validated['guest_name'] ?? $deviceId, 'file_path' => $url, 'thumbnail_path' => $thumbUrl, 'likes_count' => 0, + + // Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default + 'emotion_id' => $this->resolveEmotionId($validated, $event->id), 'is_featured' => 0, 'metadata' => null, 'created_at' => now(), @@ -245,4 +455,46 @@ class EventPublicController extends BaseController 'thumbnail_path' => $thumbUrl, ], 201); } + + /** + * Resolve emotion_id from validation data, supporting both direct ID and slug lookup + */ + private function resolveEmotionId(array $validated, int $eventId): int + { + // 1. Use explicit emotion_id if provided + if (isset($validated['emotion_id']) && $validated['emotion_id'] !== null) { + return (int) $validated['emotion_id']; + } + + // 2. Resolve from emotion_slug if provided (format: 'emotion-{id}') + if (isset($validated['emotion_slug']) && $validated['emotion_slug']) { + $slug = $validated['emotion_slug']; + if (str_starts_with($slug, 'emotion-')) { + $id = (int) substr($slug, 8); // Remove 'emotion-' prefix + if ($id > 0) { + // Verify emotion exists and is available for this event + $exists = DB::table('emotions') + ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id') + ->join('events', 'emotion_event_type.event_type_id', '=', 'events.event_type_id') + ->where('emotions.id', $id) + ->where('events.id', $eventId) + ->exists(); + + if ($exists) { + return $id; + } + } + } + } + + // 3. Fallback: Get first available emotion for this event type + $defaultEmotion = DB::table('emotions') + ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id') + ->join('events', 'emotion_event_type.event_type_id', '=', 'events.event_type_id') + ->where('events.id', $eventId) + ->orderBy('emotions.sort_order') + ->value('emotions.id'); + + return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists) + } } diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php new file mode 100644 index 0000000..70ca421 --- /dev/null +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -0,0 +1,18 @@ + + */ + protected $except = [ + 'api/v1/photos/*/like', + 'api/v1/events/*/upload', + ]; +} \ No newline at end of file diff --git a/app/Models/TaskCollection.php b/app/Models/TaskCollection.php new file mode 100644 index 0000000..ac76712 --- /dev/null +++ b/app/Models/TaskCollection.php @@ -0,0 +1,67 @@ + 'array', + 'description' => 'array', + ]; + + /** + * Tasks in this collection + */ + public function tasks(): BelongsToMany + { + return $this->belongsToMany( + Task::class, + 'task_collection_task', + 'task_collection_id', + 'task_id' + )->withTimestamps(); + } + + /** + * Events that use this collection + */ + public function events(): BelongsToMany + { + return $this->belongsToMany( + Event::class, + 'event_task_collection', + 'task_collection_id', + 'event_id' + )->withTimestamps(); + } + + /** + * Get the localized name for the current locale + */ + public function getLocalizedNameAttribute(): string + { + $locale = app()->getLocale(); + return $this->name[$locale] ?? $this->name['de'] ?? 'Unnamed Collection'; + } + + /** + * Get the localized description for the current locale + */ + public function getLocalizedDescriptionAttribute(): ?string + { + if (!$this->description) return null; + + $locale = app()->getLocale(); + return $this->description[$locale] ?? $this->description['de'] ?? null; + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index 24baf8c..2ebd12b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,6 +11,7 @@ use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/database/migrations/2025_09_12_095200_create_event_task_collection_table.php b/database/migrations/2025_09_12_095200_create_event_task_collection_table.php new file mode 100644 index 0000000..ba26c30 --- /dev/null +++ b/database/migrations/2025_09_12_095200_create_event_task_collection_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('event_id')->constrained('events')->onDelete('cascade'); + $table->foreignId('task_collection_id')->constrained('task_collections')->onDelete('cascade'); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + // Composite unique index to prevent duplicate assignments + $table->unique(['event_id', 'task_collection_id']); + + $table->index(['event_id', 'sort_order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('event_task_collection'); + } +}; \ No newline at end of file diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 2f336b7..f0b9331 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -19,6 +19,7 @@ class DatabaseSeeder extends Seeder EmotionsSeeder::class, DemoEventSeeder::class, TasksSeeder::class, + TaskCollectionsSeeder::class, DemoAchievementsSeeder::class, ]); diff --git a/database/seeders/TaskCollectionsSeeder.php b/database/seeders/TaskCollectionsSeeder.php new file mode 100644 index 0000000..2f587ef --- /dev/null +++ b/database/seeders/TaskCollectionsSeeder.php @@ -0,0 +1,90 @@ +where('slug', 'demo-wedding-2025')->value('id'); + if (!$demoEventId) { + $this->command->info('Demo event not found, skipping task collections seeding'); + return; + } + + // Get some task IDs for demo (assuming TasksSeeder was run) + $taskIds = DB::table('tasks')->limit(6)->pluck('id')->toArray(); + if (empty($taskIds)) { + $this->command->info('No tasks found, skipping task collections seeding'); + return; + } + + // Create Wedding Task Collection + $weddingCollectionId = DB::table('task_collections')->insertGetId([ + 'name' => json_encode([ + 'de' => 'Hochzeitsaufgaben', + 'en' => 'Wedding Tasks' + ]), + 'description' => json_encode([ + 'de' => 'Spezielle Aufgaben für Hochzeitsgäste', + 'en' => 'Special tasks for wedding guests' + ]), + ]); + + // Assign first 4 tasks to wedding collection + $weddingTasks = array_slice($taskIds, 0, 4); + foreach ($weddingTasks as $taskId) { + DB::table('task_collection_task')->insert([ + 'task_collection_id' => $weddingCollectionId, + 'task_id' => $taskId, + ]); + } + + // Link wedding collection to demo event + DB::table('event_task_collection')->insert([ + 'event_id' => $demoEventId, + 'task_collection_id' => $weddingCollectionId, + 'sort_order' => 1, + ]); + + // Create General Fun Tasks Collection (fallback) + $funCollectionId = DB::table('task_collections')->insertGetId([ + 'name' => json_encode([ + 'de' => 'Spaß-Aufgaben', + 'en' => 'Fun Tasks' + ]), + 'description' => json_encode([ + 'de' => 'Allgemeine unterhaltsame Aufgaben', + 'en' => 'General entertaining tasks' + ]), + ]); + + // Assign remaining tasks to fun collection + $funTasks = array_slice($taskIds, 4); + foreach ($funTasks as $taskId) { + DB::table('task_collection_task')->insert([ + 'task_collection_id' => $funCollectionId, + 'task_id' => $taskId, + ]); + } + + // Link fun collection to demo event as fallback + DB::table('event_task_collection')->insert([ + 'event_id' => $demoEventId, + 'task_collection_id' => $funCollectionId, + 'sort_order' => 2, + ]); + + $this->command->info("✅ Created 2 task collections with " . count($taskIds) . " tasks for demo event"); + $this->command->info("Wedding Collection ID: {$weddingCollectionId}"); + $this->command->info("Fun Collection ID: {$funCollectionId}"); + } +} \ No newline at end of file diff --git a/resources/js/guest/components/BottomNav.tsx b/resources/js/guest/components/BottomNav.tsx index 919d2d3..a8bbe3b 100644 --- a/resources/js/guest/components/BottomNav.tsx +++ b/resources/js/guest/components/BottomNav.tsx @@ -1,11 +1,28 @@ import React from 'react'; -import { NavLink, useParams } from 'react-router-dom'; +import { NavLink, useParams, useLocation } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { GalleryHorizontal, Home, Trophy } from 'lucide-react'; +import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react'; +import { useEventData } from '../hooks/useEventData'; -function TabLink({ to, children }: { to: string; children: React.ReactNode }) { +function TabLink({ + to, + children, + isActive +}: { + to: string; + children: React.ReactNode; + isActive: boolean; +}) { return ( - (isActive ? 'text-foreground' : 'text-muted-foreground')}> + {children} ); @@ -13,25 +30,60 @@ function TabLink({ to, children }: { to: string; children: React.ReactNode }) { export default function BottomNav() { const { slug } = useParams(); + const location = useLocation(); + const { event } = useEventData(); + if (!slug) return null; // Only show bottom nav within event context const base = `/e/${encodeURIComponent(slug)}`; + const currentPath = location.pathname; + const locale = event?.default_locale || 'de'; + + // Translations + const translations = { + de: { + home: 'Start', + tasks: 'Aufgaben', + achievements: 'Erfolge', + gallery: 'Galerie' + }, + en: { + home: 'Home', + tasks: 'Tasks', + achievements: 'Achievements', + gallery: 'Gallery' + } + }; + + const t = translations[locale as keyof typeof translations] || translations.de; + + // Improved active state logic + const isHomeActive = currentPath === base || currentPath === `/${slug}`; + const isTasksActive = currentPath.startsWith(`${base}/tasks`) || currentPath === `${base}/upload`; + const isAchievementsActive = currentPath.startsWith(`${base}/achievements`); + const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`); + return ( -
-
- - +
+
+ +
+ {t.home} +
- - + +
+ {t.tasks} +
- - + +
+ {t.achievements} +
+
+ +
+ {t.gallery} +
diff --git a/resources/js/guest/components/EmotionPicker.tsx b/resources/js/guest/components/EmotionPicker.tsx new file mode 100644 index 0000000..63deb9d --- /dev/null +++ b/resources/js/guest/components/EmotionPicker.tsx @@ -0,0 +1,149 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { ChevronRight } from 'lucide-react'; + +interface Emotion { + id: number; + slug: string; + name: string; + emoji: string; + description?: string; +} + +interface EmotionPickerProps { + onSelect?: (emotion: Emotion) => void; +} + +export default function EmotionPicker({ onSelect }: EmotionPickerProps) { + const { slug } = useParams<{ slug: string }>(); + const navigate = useNavigate(); + const [emotions, setEmotions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Fallback emotions (when API not available yet) + const fallbackEmotions: Emotion[] = [ + { id: 1, slug: 'happy', name: 'Glücklich', emoji: '😊' }, + { id: 2, slug: 'love', name: 'Verliebt', emoji: '❤️' }, + { id: 3, slug: 'excited', name: 'Aufgeregt', emoji: '🎉' }, + { id: 4, slug: 'relaxed', name: 'Entspannt', emoji: '😌' }, + { id: 5, slug: 'sad', name: 'Traurig', emoji: '😢' }, + { id: 6, slug: 'surprised', name: 'Überrascht', emoji: '😲' }, + ]; + + useEffect(() => { + if (!slug) return; + + async function fetchEmotions() { + try { + setLoading(true); + setError(null); + + // Try API first + const response = await fetch(`/api/v1/events/${slug}/emotions`); + if (response.ok) { + const data = await response.json(); + setEmotions(Array.isArray(data) ? data : fallbackEmotions); + } else { + // Fallback to predefined emotions + console.warn('Emotions API not available, using fallback'); + setEmotions(fallbackEmotions); + } + } catch (err) { + console.error('Failed to fetch emotions:', err); + setError('Emotions konnten nicht geladen werden'); + setEmotions(fallbackEmotions); + } finally { + setLoading(false); + } + } + + fetchEmotions(); + }, [slug]); + + const handleEmotionSelect = (emotion: Emotion) => { + if (onSelect) { + onSelect(emotion); + } else { + // Default: Navigate to tasks with emotion filter + navigate(`/e/${slug}/tasks?emotion=${emotion.slug}`); + } + }; + + if (loading) { + return ( +
+
Lade Emotionen...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+

+ Wie fühlst du dich? + (optional) +

+ +
+ {emotions.map((emotion) => { + // Localize name and description if they are JSON + const localize = (value: string | object, defaultValue: string = ''): string => { + if (typeof value === 'string' && value.startsWith('{')) { + try { + const data = JSON.parse(value as string); + return data.de || data.en || defaultValue || ''; + } catch { + return value as string; + } + } + return value as string; + }; + + const localizedName = localize(emotion.name, emotion.name); + const localizedDescription = localize(emotion.description || '', ''); + + return ( + + ); + })} +
+ + {/* Skip option */} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/resources/js/guest/components/GalleryPreview.tsx b/resources/js/guest/components/GalleryPreview.tsx index a82d646..937ed25 100644 --- a/resources/js/guest/components/GalleryPreview.tsx +++ b/resources/js/guest/components/GalleryPreview.tsx @@ -2,39 +2,89 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; +import { getDeviceId } from '../lib/device'; import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; type Props = { slug: string }; export default function GalleryPreview({ slug }: Props) { const { photos, loading } = usePollGalleryDelta(slug); - const [mode, setMode] = React.useState<'latest' | 'popular'>('latest'); + const [mode, setMode] = React.useState<'latest' | 'popular' | 'myphotos'>('latest'); const items = React.useMemo(() => { - const arr = photos.slice(); + let arr = photos.slice(); + + // MyPhotos filter (requires session_id matching) + if (mode === 'myphotos') { + const deviceId = getDeviceId(); + arr = arr.filter((photo: any) => photo.session_id === deviceId); + } + + // Sorting if (mode === 'popular') { arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0)); } else { arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); } - return arr.slice(0, 6); + + return arr.slice(0, 4); // 2x2 = 4 items }, [photos, mode]); + // Helper function to generate photo title (must be before return) + function getPhotoTitle(photo: any): string { + if (photo.task_id) { + return `Task: ${photo.task_title || 'Unbekannte Aufgabe'}`; + } + if (photo.emotion_id) { + return `Emotion: ${photo.emotion_name || 'Gefühl'}`; + } + // Fallback based on creation time or placeholder + const now = new Date(); + const created = new Date(photo.created_at || now); + const hours = created.getHours(); + if (hours < 12) return 'Morgenmoment'; + if (hours < 18) return 'Nachmittagslicht'; + return 'Abendstimmung'; + } + return (
-
-
+
+
+ className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${ + mode === 'latest' + ? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105' + : 'text-gray-600 hover:bg-pink-50 hover:text-pink-700' + }`} + > + Newest + + className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${ + mode === 'popular' + ? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105' + : 'text-gray-600 hover:bg-pink-50 hover:text-pink-700' + }`} + > + Popular + +
-
- + + Alle ansehen → +
{loading &&

Lädt…

} @@ -45,15 +95,28 @@ export default function GalleryPreview({ slug }: Props) { )} -
+
{items.map((p: any) => ( - - Foto + +
+ {p.title + {/* Photo Title */} +
+
+ {p.title || getPhotoTitle(p)} +
+ {p.likes_count > 0 && ( +
+ ❤️ {p.likes_count} +
+ )} +
+
))}
diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index 721b617..c2e1784 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -3,13 +3,99 @@ import { Button } from '@/components/ui/button'; import { Link } from 'react-router-dom'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; import AppearanceToggleDropdown from '@/components/appearance-dropdown'; -import { Settings, ChevronDown } from 'lucide-react'; +import { Settings, ChevronDown, User } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { useEventData } from '../hooks/useEventData'; +import { usePollStats } from '../polling/usePollStats'; + +export default function Header({ slug, title = '' }: { slug?: string; title?: string }) { + if (!slug) { + return ( +
+
{title}
+
+ + +
+
+ ); + } + + const { event, loading: eventLoading, error: eventError } = useEventData(); + const stats = usePollStats(slug); + + if (eventLoading) { + return ( +
+
Lade Event...
+
+ + +
+
+ ); + } + + if (eventError || !event) { + return ( +
+
Event nicht gefunden
+
+ + +
+
+ ); + } + + // Get event icon or generate initials + const getEventAvatar = (event: any) => { + if (event.type?.icon) { + return ( +
+ {event.type.icon} +
+ ); + } + + // Fallback to initials + const getInitials = (name: string) => { + const words = name.split(' '); + if (words.length >= 2) { + return `${words[0][0]}${words[1][0]}`.toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); + }; + + return ( +
+ {getInitials(event.name)} +
+ ); + }; -export default function Header({ title = '' }: { title?: string }) { return (
-
{title}
+
+ {getEventAvatar(event)} +
+
{event.name}
+
+ {stats && ( + <> + + + {stats.onlineGuests} online + + + + {stats.tasksSolved} Aufgaben gelöst + + + )} +
+
+
diff --git a/resources/js/guest/hooks/useEventData.ts b/resources/js/guest/hooks/useEventData.ts new file mode 100644 index 0000000..8deabee --- /dev/null +++ b/resources/js/guest/hooks/useEventData.ts @@ -0,0 +1,40 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { fetchEvent, EventData } from '../services/eventApi'; + +export function useEventData() { + const { slug } = useParams<{ slug: string }>(); + const [event, setEvent] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!slug) { + setError('No event slug provided'); + setLoading(false); + return; + } + + const loadEvent = async () => { + try { + setLoading(true); + setError(null); + const eventData = await fetchEvent(slug); + setEvent(eventData); + } catch (err) { + console.error('Failed to load event:', err); + setError(err instanceof Error ? err.message : 'Failed to load event'); + } finally { + setLoading(false); + } + }; + + loadEvent(); + }, [slug]); + + return { + event, + loading, + error, + }; +} \ No newline at end of file diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index 2ae020d..8003f7d 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -66,21 +66,77 @@ export default function GalleryPage() { )} {loading &&

Lade…

}
- {list.map((p) => ( - - - - Foto - - -
- - {counts[p.id] ?? p.likes_count ?? 0} -
-
- ))} + {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 ( + + + + {`Foto { + 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" + /> + + + + {p.task_title && ( +
+

{p.task_title}

+
+ )} + +
+ + {counts[p.id] ?? (p.likes_count || 0)} +
+
+ ); + })}
); diff --git a/resources/js/guest/pages/HomePage.tsx b/resources/js/guest/pages/HomePage.tsx index 30365bf..088168e 100644 --- a/resources/js/guest/pages/HomePage.tsx +++ b/resources/js/guest/pages/HomePage.tsx @@ -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 ( - - - {stats.loading ? 'Lade…' : ( - - {stats.onlineGuests} Gäste online · ✅{' '} - {stats.tasksSolved} Aufgaben gelöst +
+
{/* Consistent spacing */} + {/* Prominent Draw Task Button */} + + - - + + + + {/* How do you feel? Section */} + + +
-
- + + {/* Bottom Navigation */} + ); } diff --git a/resources/js/guest/pages/PhotoLightbox.tsx b/resources/js/guest/pages/PhotoLightbox.tsx index 6a764ed..f3489eb 100644 --- a/resources/js/guest/pages/PhotoLightbox.tsx +++ b/resources/js/guest/pages/PhotoLightbox.tsx @@ -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((state as any)?.photo ?? null); - + + const [photo, setPhoto] = React.useState(null); + const [task, setTask] = React.useState(null); + const [taskLoading, setTaskLoading] = React.useState(false); const [likes, setLikes] = React.useState(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 ( - + + {/* Header with controls */}
-
- +
-
+ + {/* Task Info Overlay */} + {task && ( +
+
+
Task: {task.title}
+ {taskLoading && ( +
Lade Aufgabe...
+ )} +
+
+ )} + + {/* Photo Display */} +
{photo ? ( - Foto + Foto { + console.error('Image load error:', e); + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> ) : ( -
Lade…
+
+
+
Lade Foto...
+
)}
+ + {/* Loading state for task */} + {taskLoading && !task && ( +
+
+
+
Lade Aufgabe...
+
+
+ )}
); diff --git a/resources/js/guest/pages/TaskPickerPage.tsx b/resources/js/guest/pages/TaskPickerPage.tsx index de8a417..5644cf2 100644 --- a/resources/js/guest/pages/TaskPickerPage.tsx +++ b/resources/js/guest/pages/TaskPickerPage.tsx @@ -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([]); + const [currentTask, setCurrentTask] = useState(null); + const [timeLeft, setTimeLeft] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + +
+ +

Lade Aufgabe...

+
+ +
+ ); + } + + if (error || !currentTask) { + return ( + +
+ +
+

Keine passende Aufgabe gefunden

+

+ {error || 'Für deine Stimmung gibt es derzeit keine Aufgaben. Versuche eine andere Stimmung oder warte auf neue Inhalte.'} +

+
+ +
+ +
+ ); + } + return ( - -

Stubs for emotion grid and random task.

+ +
+ {/* Task Header with Selfie Overlay */} +
+
+ {/* Selfie Placeholder */} +
+
+
+ 📸 +
+

+ Selfie-Vorschau +

+
+
+ + {/* Timer */} +
+
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`}> + + {formatTime(timeLeft)} +
+
+ + {/* Task Description Overlay */} +
+
+

+ {currentTask.title} +

+

+ {currentTask.description} +

+ {currentTask.instructions && ( +
+

💡 {currentTask.instructions}

+
+ )} +
+
+
+
+ + {/* Action Buttons */} +
+ + +
+ + + +
+
+ + {/* Bottom Navigation */} + +
); } - diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index 2dbca6c..14637df 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -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([]); - const queue = useUploadQueue(); - const [progressMap, setProgressMap] = React.useState>({}); + 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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [uploading, setUploading] = useState(false); - async function onPick(e: React.ChangeEvent) { - const files = Array.from(e.target.files ?? []).slice(0, 10); - const results: Item[] = []; - for (const f of files) { + // Camera state + const videoRef = useRef(null); + const canvasRef = useRef(null); + const [stream, setStream] = useState(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 ( + +
+ +

Kamera wird gestartet...

+
+ +
+ ); } - 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 ( + +
+ +
+

Kamera nicht verfügbar

+

{error}

+ +
+
+ +
+ ); } + if (uploading) { + return ( + +
+
+

Foto wird hochgeladen

+

Bitte warten... Dies kann einen Moment dauern.

+ +
+ +
+ ); + } + + const difficultyColor = task.difficulty === 'easy' ? 'text-green-400' : + task.difficulty === 'medium' ? 'text-yellow-400' : 'text-red-400'; + return ( - - -
- -
- {items.map((it, i) => ( -
-
-
{it.file.name}
-
- {formatBytes(it.out?.size ?? it.file.size)} -
-
- {it.done &&
Fertig
} - {it.error &&
{it.error}
} -
- ))} -
-
-
Warteschlange
- {queue.loading ? ( -
Lade…
- ) : queue.items.length === 0 ? ( -
Keine offenen Uploads.
- ) : ( -
- {queue.items.map((q) => ( -
-
-
{q.fileName}
-
{q.status}{q.status==='uploading' && typeof q.id==='number' ? ` • ${progressMap[q.id] ?? 0}%` : ''}
+ +
+ {/* Camera Preview Container */} +
+ {/* Video Background */} +