fixed like action, better dark mode, bottom navigation working, added taskcollection
This commit is contained in:
@@ -13,6 +13,14 @@ use App\Support\ImageHelper;
|
|||||||
|
|
||||||
class EventPublicController extends BaseController
|
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
|
private function toPublicUrl(?string $path): ?string
|
||||||
{
|
{
|
||||||
if (! $path) return null;
|
if (! $path) return null;
|
||||||
@@ -46,13 +54,35 @@ class EventPublicController extends BaseController
|
|||||||
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found']], 404);
|
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([
|
return response()->json([
|
||||||
'id' => $event->id,
|
'id' => $event->id,
|
||||||
'slug' => $event->slug,
|
'slug' => $event->slug,
|
||||||
'name' => $event->name,
|
'name' => $localizedName,
|
||||||
'default_locale' => $event->default_locale,
|
'default_locale' => $event->default_locale,
|
||||||
'created_at' => $event->created_at,
|
'created_at' => $event->created_at,
|
||||||
'updated_at' => $event->updated_at,
|
'updated_at' => $event->updated_at,
|
||||||
|
'type' => $eventTypeData,
|
||||||
])->header('Cache-Control', 'no-store');
|
])->header('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +125,139 @@ class EventPublicController extends BaseController
|
|||||||
->header('ETag', $etag);
|
->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)
|
public function photos(Request $request, string $slug)
|
||||||
{
|
{
|
||||||
$event = DB::table('events')->where('slug', $slug)->first(['id']);
|
$event = DB::table('events')->where('slug', $slug)->first(['id']);
|
||||||
@@ -103,20 +266,46 @@ class EventPublicController extends BaseController
|
|||||||
}
|
}
|
||||||
$eventId = $event->id;
|
$eventId = $event->id;
|
||||||
|
|
||||||
|
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
||||||
|
$filter = $request->query('filter');
|
||||||
|
|
||||||
$since = $request->query('since');
|
$since = $request->query('since');
|
||||||
$query = DB::table('photos')
|
$query = DB::table('photos')
|
||||||
->select(['id', 'file_path', 'thumbnail_path', 'likes_count', 'emotion_id', 'task_id', 'created_at'])
|
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||||||
->where('event_id', $eventId)
|
->select([
|
||||||
->orderByDesc('created_at')
|
'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);
|
->limit(60);
|
||||||
|
|
||||||
|
// MyPhotos filter
|
||||||
|
if ($filter === 'myphotos' && $deviceId !== 'anon') {
|
||||||
|
$query->where('guest_name', $deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
if ($since) {
|
if ($since) {
|
||||||
$query->where('created_at', '>', $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->file_path = $this->toPublicUrl((string)($r->file_path ?? ''));
|
||||||
$r->thumbnail_path = $this->toPublicUrl((string)($r->thumbnail_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;
|
return $r;
|
||||||
});
|
});
|
||||||
$latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
|
$latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
|
||||||
@@ -124,7 +313,7 @@ class EventPublicController extends BaseController
|
|||||||
'data' => $rows,
|
'data' => $rows,
|
||||||
'latest_photo_at' => $latestPhotoAt,
|
'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');
|
$reqEtag = request()->headers->get('If-None-Match');
|
||||||
if ($reqEtag && $reqEtag === $etag) {
|
if ($reqEtag && $reqEtag === $etag) {
|
||||||
return response('', 304);
|
return response('', 304);
|
||||||
@@ -138,14 +327,32 @@ class EventPublicController extends BaseController
|
|||||||
public function photo(int $id)
|
public function photo(int $id)
|
||||||
{
|
{
|
||||||
$row = DB::table('photos')
|
$row = DB::table('photos')
|
||||||
->select(['id', 'event_id', 'file_path', 'thumbnail_path', 'likes_count', 'emotion_id', 'task_id', 'created_at'])
|
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||||||
->where('id', $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();
|
->first();
|
||||||
if (! $row) {
|
if (! $row) {
|
||||||
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found']], 404);
|
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found']], 404);
|
||||||
}
|
}
|
||||||
$row->file_path = $this->toPublicUrl((string)($row->file_path ?? ''));
|
$row->file_path = $this->toPublicUrl((string)($row->file_path ?? ''));
|
||||||
$row->thumbnail_path = $this->toPublicUrl((string)($row->thumbnail_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');
|
return response()->json($row)->header('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +418,7 @@ class EventPublicController extends BaseController
|
|||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'photo' => ['required', 'image', 'max:6144'], // 6 MB
|
'photo' => ['required', 'image', 'max:6144'], // 6 MB
|
||||||
'emotion_id' => ['nullable', 'integer'],
|
'emotion_id' => ['nullable', 'integer'],
|
||||||
|
'emotion_slug' => ['nullable', 'string'],
|
||||||
'task_id' => ['nullable', 'integer'],
|
'task_id' => ['nullable', 'integer'],
|
||||||
'guest_name' => ['nullable', 'string', 'max:255'],
|
'guest_name' => ['nullable', 'string', 'max:255'],
|
||||||
]);
|
]);
|
||||||
@@ -227,12 +435,14 @@ class EventPublicController extends BaseController
|
|||||||
|
|
||||||
$id = DB::table('photos')->insertGetId([
|
$id = DB::table('photos')->insertGetId([
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'emotion_id' => $validated['emotion_id'] ?? null,
|
|
||||||
'task_id' => $validated['task_id'] ?? null,
|
'task_id' => $validated['task_id'] ?? null,
|
||||||
'guest_name' => $validated['guest_name'] ?? $deviceId,
|
'guest_name' => $validated['guest_name'] ?? $deviceId,
|
||||||
'file_path' => $url,
|
'file_path' => $url,
|
||||||
'thumbnail_path' => $thumbUrl,
|
'thumbnail_path' => $thumbUrl,
|
||||||
'likes_count' => 0,
|
'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,
|
'is_featured' => 0,
|
||||||
'metadata' => null,
|
'metadata' => null,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
@@ -245,4 +455,46 @@ class EventPublicController extends BaseController
|
|||||||
'thumbnail_path' => $thumbUrl,
|
'thumbnail_path' => $thumbUrl,
|
||||||
], 201);
|
], 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
app/Http/Middleware/VerifyCsrfToken.php
Normal file
18
app/Http/Middleware/VerifyCsrfToken.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
|
||||||
|
|
||||||
|
class VerifyCsrfToken extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The URIs that should be excluded from CSRF verification.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $except = [
|
||||||
|
'api/v1/photos/*/like',
|
||||||
|
'api/v1/events/*/upload',
|
||||||
|
];
|
||||||
|
}
|
||||||
67
app/Models/TaskCollection.php
Normal file
67
app/Models/TaskCollection.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
class TaskCollection extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'task_collections';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'name' => '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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
|||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('event_task_collection', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
EmotionsSeeder::class,
|
EmotionsSeeder::class,
|
||||||
DemoEventSeeder::class,
|
DemoEventSeeder::class,
|
||||||
TasksSeeder::class,
|
TasksSeeder::class,
|
||||||
|
TaskCollectionsSeeder::class,
|
||||||
DemoAchievementsSeeder::class,
|
DemoAchievementsSeeder::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
90
database/seeders/TaskCollectionsSeeder.php
Normal file
90
database/seeders/TaskCollectionsSeeder.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class TaskCollectionsSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Get demo event ID
|
||||||
|
$demoEventId = DB::table('events')->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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,28 @@
|
|||||||
import React from 'react';
|
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 { 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 (
|
return (
|
||||||
<NavLink to={to} className={({ isActive }) => (isActive ? 'text-foreground' : 'text-muted-foreground')}>
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
className={`
|
||||||
|
flex flex-col items-center gap-1 h-14 p-2 transition-all duration-200 rounded-lg backdrop-blur-md
|
||||||
|
${isActive
|
||||||
|
? 'bg-gradient-to-t from-pink-500/90 to-pink-400/90 text-white shadow-lg scale-105 border border-white/30'
|
||||||
|
: 'text-gray-300 hover:bg-white/10 hover:text-pink-300 hover:scale-105 hover:border-white/20 border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
@@ -13,25 +30,60 @@ function TabLink({ to, children }: { to: string; children: React.ReactNode }) {
|
|||||||
|
|
||||||
export default function BottomNav() {
|
export default function BottomNav() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
|
const location = useLocation();
|
||||||
|
const { event } = useEventData();
|
||||||
|
|
||||||
if (!slug) return null; // Only show bottom nav within event context
|
if (!slug) return null; // Only show bottom nav within event context
|
||||||
const base = `/e/${encodeURIComponent(slug)}`;
|
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 (
|
return (
|
||||||
<div className="fixed inset-x-0 bottom-0 z-20 border-t bg-white/90 px-3 py-2 backdrop-blur dark:bg-black/40">
|
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-black/30 px-2 py-2 backdrop-blur-xl shadow-xl dark:bg-black/40 dark:border-gray-800/50">
|
||||||
<div className="mx-auto flex max-w-md items-center justify-between">
|
<div className="mx-auto flex max-w-sm items-center justify-around">
|
||||||
<TabLink to={`${base}`}>
|
<TabLink to={`${base}`} isActive={isHomeActive}>
|
||||||
<Button variant="ghost" size="sm" className="flex flex-col gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Home className="h-5 w-5" /> <span className="text-xs">Start</span>
|
<Home className="h-5 w-5" /> <span className="text-xs">{t.home}</span>
|
||||||
</Button>
|
</div>
|
||||||
</TabLink>
|
</TabLink>
|
||||||
<TabLink to={`${base}/gallery`}>
|
<TabLink to={`${base}/tasks`} isActive={isTasksActive}>
|
||||||
<Button variant="ghost" size="sm" className="flex flex-col gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<GalleryHorizontal className="h-5 w-5" /> <span className="text-xs">Galerie</span>
|
<CheckSquare className="h-5 w-5" /> <span className="text-xs">{t.tasks}</span>
|
||||||
</Button>
|
</div>
|
||||||
</TabLink>
|
</TabLink>
|
||||||
<TabLink to={`${base}/achievements`}>
|
<TabLink to={`${base}/achievements`} isActive={isAchievementsActive}>
|
||||||
<Button variant="ghost" size="sm" className="flex flex-col gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Trophy className="h-5 w-5" /> <span className="text-xs">Erfolge</span>
|
<Trophy className="h-5 w-5" /> <span className="text-xs">{t.achievements}</span>
|
||||||
</Button>
|
</div>
|
||||||
|
</TabLink>
|
||||||
|
<TabLink to={`${base}/gallery`} isActive={isGalleryActive}>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<GalleryHorizontal className="h-5 w-5" /> <span className="text-xs">{t.gallery}</span>
|
||||||
|
</div>
|
||||||
</TabLink>
|
</TabLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
149
resources/js/guest/components/EmotionPicker.tsx
Normal file
149
resources/js/guest/components/EmotionPicker.tsx
Normal file
@@ -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<Emotion[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="mb-6 p-4 text-center">
|
||||||
|
<div className="text-sm text-muted-foreground">Lade Emotionen...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<div className="text-sm text-red-700">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
Wie fühlst du dich?
|
||||||
|
<span className="text-xs text-muted-foreground">(optional)</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
{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 (
|
||||||
|
<Button
|
||||||
|
key={emotion.id}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start h-16 p-3 bg-pink-50 dark:bg-gray-800/50 hover:bg-pink-100 dark:hover:bg-gray-700/50 border-pink-200 dark:border-gray-600 rounded-xl text-left shadow-sm dark:text-white"
|
||||||
|
onClick={() => handleEmotionSelect(emotion)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{emotion.emoji}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-sm truncate">{localizedName}</div>
|
||||||
|
{localizedDescription && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{localizedDescription}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground ml-auto" />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skip option */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full text-sm text-gray-600 dark:text-gray-300 hover:text-pink-600 hover:bg-pink-50 dark:hover:bg-gray-800 border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"
|
||||||
|
onClick={() => navigate(`/e/${slug}/tasks`)}
|
||||||
|
>
|
||||||
|
Überspringen und Aufgabe wählen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,39 +2,89 @@ import React from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { getDeviceId } from '../lib/device';
|
||||||
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||||
|
|
||||||
type Props = { slug: string };
|
type Props = { slug: string };
|
||||||
|
|
||||||
export default function GalleryPreview({ slug }: Props) {
|
export default function GalleryPreview({ slug }: Props) {
|
||||||
const { photos, loading } = usePollGalleryDelta(slug);
|
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 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') {
|
if (mode === 'popular') {
|
||||||
arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
|
arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
|
||||||
} else {
|
} else {
|
||||||
arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
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]);
|
}, [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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="inline-flex rounded-md border p-1 text-xs">
|
<div className="inline-flex rounded-full bg-white/80 backdrop-blur-sm border border-pink-200 p-1 shadow-sm">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode('latest')}
|
onClick={() => setMode('latest')}
|
||||||
className={`px-2 py-1 ${mode === 'latest' ? 'rounded-sm bg-muted font-medium' : ''}`}
|
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
|
||||||
>Neueste</button>
|
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
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode('popular')}
|
onClick={() => setMode('popular')}
|
||||||
className={`px-2 py-1 ${mode === 'popular' ? 'rounded-sm bg-muted font-medium' : ''}`}
|
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
|
||||||
>Beliebt</button>
|
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
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('myphotos')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
|
||||||
|
mode === 'myphotos'
|
||||||
|
? '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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
My Photos
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grow" />
|
<Link to={`/e/${slug}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
|
||||||
<Link to={`../gallery`}><Button variant="link" className="px-0">Alle ansehen →</Button></Link>
|
Alle ansehen →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-muted-foreground">Lädt…</p>}
|
{loading && <p className="text-sm text-muted-foreground">Lädt…</p>}
|
||||||
@@ -45,15 +95,28 @@ export default function GalleryPreview({ slug }: Props) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{items.map((p: any) => (
|
{items.map((p: any) => (
|
||||||
<Link key={p.id} to={`../photo/${p.id}`} state={{ photo: p }}>
|
<Link key={p.id} to={`/e/${slug}/photo/${p.id}`} state={{ photo: p }} className="block">
|
||||||
<img
|
<div className="relative">
|
||||||
src={p.thumbnail_path || p.file_path}
|
<img
|
||||||
alt="Foto"
|
src={p.thumbnail_path || p.file_path}
|
||||||
className="aspect-square w-full rounded object-cover"
|
alt={p.title || 'Foto'}
|
||||||
loading="lazy"
|
className="aspect-square w-full rounded-xl object-cover shadow-lg hover:shadow-xl transition-shadow duration-200"
|
||||||
/>
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{/* Photo Title */}
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-xs font-medium text-gray-900 line-clamp-2 bg-white/80 px-2 py-1 rounded-md">
|
||||||
|
{p.title || getPhotoTitle(p)}
|
||||||
|
</div>
|
||||||
|
{p.likes_count > 0 && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-xs text-pink-600">
|
||||||
|
❤️ {p.likes_count}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,99 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
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 { 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 (
|
||||||
|
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||||
|
<div className="font-semibold">{title}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AppearanceToggleDropdown />
|
||||||
|
<SettingsSheet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { event, loading: eventLoading, error: eventError } = useEventData();
|
||||||
|
const stats = usePollStats(slug);
|
||||||
|
|
||||||
|
if (eventLoading) {
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||||
|
<div className="font-semibold">Lade Event...</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AppearanceToggleDropdown />
|
||||||
|
<SettingsSheet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventError || !event) {
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||||
|
<div className="font-semibold text-red-600">Event nicht gefunden</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AppearanceToggleDropdown />
|
||||||
|
<SettingsSheet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get event icon or generate initials
|
||||||
|
const getEventAvatar = (event: any) => {
|
||||||
|
if (event.type?.icon) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 text-xl">
|
||||||
|
{event.type.icon}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-100 text-pink-600 font-semibold text-sm">
|
||||||
|
{getInitials(event.name)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function Header({ title = '' }: { title?: string }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||||
<div className="font-semibold">{title}</div>
|
<div className="flex items-center gap-3">
|
||||||
|
{getEventAvatar(event)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="font-semibold text-base">{event.name}</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{stats && (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
<span>{stats.onlineGuests} online</span>
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="font-medium">{stats.tasksSolved}</span> Aufgaben gelöst
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AppearanceToggleDropdown />
|
<AppearanceToggleDropdown />
|
||||||
<SettingsSheet />
|
<SettingsSheet />
|
||||||
|
|||||||
40
resources/js/guest/hooks/useEventData.ts
Normal file
40
resources/js/guest/hooks/useEventData.ts
Normal file
@@ -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<EventData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -66,21 +66,77 @@ export default function GalleryPage() {
|
|||||||
)}
|
)}
|
||||||
{loading && <p>Lade…</p>}
|
{loading && <p>Lade…</p>}
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
{list.map((p) => (
|
{list.map((p: any) => {
|
||||||
<Card key={p.id} className="relative overflow-hidden">
|
// Debug: Log image URLs
|
||||||
<CardContent className="p-0">
|
const imgSrc = p.thumbnail_path || p.file_path;
|
||||||
<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" />
|
// Normalize image URL
|
||||||
</Link>
|
let imageUrl = imgSrc;
|
||||||
</CardContent>
|
let cleanPath = '';
|
||||||
<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">
|
if (imageUrl) {
|
||||||
<Heart className="h-4 w-4" />
|
// Remove leading/trailing slashes for processing
|
||||||
</button>
|
cleanPath = imageUrl.replace(/^\/+|\/+$/g, '');
|
||||||
<span className="text-xs">{counts[p.id] ?? p.likes_count ?? 0}</span>
|
|
||||||
</div>
|
// Check if path already contains storage prefix
|
||||||
</Card>
|
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>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,33 +2,36 @@ import React from 'react';
|
|||||||
import { Page } from './_util';
|
import { Page } from './_util';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { usePollStats } from '../polling/usePollStats';
|
import { usePollStats } from '../polling/usePollStats';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import Header from '../components/Header';
|
||||||
|
import EmotionPicker from '../components/EmotionPicker';
|
||||||
import GalleryPreview from '../components/GalleryPreview';
|
import GalleryPreview from '../components/GalleryPreview';
|
||||||
|
import BottomNav from '../components/BottomNav';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
const stats = usePollStats(slug!);
|
const stats = usePollStats(slug!);
|
||||||
return (
|
return (
|
||||||
<Page title={`Event: ${slug}`}>
|
<Page title={`Event: ${slug}`}>
|
||||||
<Card>
|
<Header slug={slug!} title={`Event: ${slug}`} />
|
||||||
<CardContent className="p-3 text-sm">
|
<div className="px-4 py-6 pb-20 space-y-6"> {/* Consistent spacing */}
|
||||||
{stats.loading ? 'Lade…' : (
|
{/* Prominent Draw Task Button */}
|
||||||
<span>
|
<Link to="tasks">
|
||||||
<span className="font-medium">{stats.onlineGuests}</span> Gäste online · ✅{' '}
|
<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="font-medium">{stats.tasksSolved}</span> Aufgaben gelöst
|
<span className="flex items-center gap-2">
|
||||||
|
🎲 Aufgabe ziehen
|
||||||
</span>
|
</span>
|
||||||
)}
|
</Button>
|
||||||
</CardContent>
|
</Link>
|
||||||
</Card>
|
|
||||||
<div className="h-3" />
|
{/* How do you feel? Section */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<EmotionPicker />
|
||||||
<Link to="tasks"><Button variant="secondary">Aufgabe ziehen</Button></Link>
|
|
||||||
<Link to="tasks"><Button variant="secondary">Wie fühlst du dich?</Button></Link>
|
<GalleryPreview slug={slug!} />
|
||||||
<Link to="upload"><Button>Einfach ein Foto machen</Button></Link>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4" />
|
|
||||||
<GalleryPreview slug={slug!} />
|
{/* Bottom Navigation */}
|
||||||
|
<BottomNav />
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,58 +5,190 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Heart } from 'lucide-react';
|
import { Heart } from 'lucide-react';
|
||||||
import { likePhoto } from '../services/photosApi';
|
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() {
|
export default function PhotoLightbox() {
|
||||||
const nav = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { state } = useLocation();
|
const location = useLocation();
|
||||||
const { photoId } = useParams();
|
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 [likes, setLikes] = React.useState<number | null>(null);
|
||||||
const [liked, setLiked] = React.useState(false);
|
const [liked, setLiked] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
// Extract event slug from URL path
|
||||||
if (photo) return;
|
const getEventSlug = () => {
|
||||||
(async () => {
|
const path = window.location.pathname;
|
||||||
const res = await fetch(`/api/v1/photos/${photoId}`);
|
const match = path.match(/^\/e\/([^\/]+)\/photo\/[^\/]+$/);
|
||||||
if (res.ok) setPhoto(await res.json());
|
return match ? match[1] : null;
|
||||||
})();
|
};
|
||||||
}, [photo, photoId]);
|
|
||||||
|
|
||||||
|
const slug = getEventSlug();
|
||||||
|
|
||||||
|
// Load photo if not passed via state
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (photo && likes === null) setLikes(photo.likes_count ?? 0);
|
const statePhoto = (location.state as any)?.photo;
|
||||||
}, [photo, likes]);
|
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() {
|
async function onLike() {
|
||||||
if (liked || !photo) return;
|
if (liked || !photo) return;
|
||||||
setLiked(true);
|
setLiked(true);
|
||||||
const c = await likePhoto(photo.id);
|
try {
|
||||||
setLikes(c);
|
const count = await likePhoto(photo.id);
|
||||||
|
setLikes(count);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Like failed:', error);
|
||||||
|
setLiked(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onOpenChange(open: boolean) {
|
function onOpenChange(open: boolean) {
|
||||||
if (!open) nav(-1);
|
if (!open) navigate(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!photo && !photoId) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={onOpenChange}>
|
<Dialog open={true} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-5xl border-0 bg-black p-0 text-white">
|
<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 justify-between p-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={onLike} disabled={liked}>
|
<Button
|
||||||
<Heart className="mr-1 h-4 w-4" /> {likes ?? 0}
|
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>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
<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 ? (
|
{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>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 { 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() {
|
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 (
|
return (
|
||||||
<Page title="Stimmung wählen / Aufgabe ziehen">
|
<Page title={currentTask.title}>
|
||||||
<p>Stubs for emotion grid and random task.</p>
|
<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>
|
</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 { Page } from './_util';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useAppearance } from '../../hooks/use-appearance';
|
||||||
import { getDeviceId } from '../lib/device';
|
import { Camera, RotateCcw, Zap, ZapOff } from 'lucide-react';
|
||||||
import { compressPhoto, formatBytes } from '../lib/image';
|
import BottomNav from '../components/BottomNav';
|
||||||
import { useUploadQueue } from '../queue/hooks';
|
import { uploadPhoto } from '../services/photosApi';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
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() {
|
export default function UploadPage() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const [items, setItems] = React.useState<Item[]>([]);
|
const [searchParams] = useSearchParams();
|
||||||
const queue = useUploadQueue();
|
const navigate = useNavigate();
|
||||||
const [progressMap, setProgressMap] = React.useState<Record<number, number>>({});
|
const { appearance } = useAppearance();
|
||||||
|
const isDark = appearance === 'dark';
|
||||||
|
|
||||||
React.useEffect(() => {
|
// Task data from URL params
|
||||||
const onProg = (e: any) => {
|
const taskId = searchParams.get('task');
|
||||||
const { id, progress } = e.detail || {};
|
const emotionSlug = searchParams.get('emotion') || '';
|
||||||
if (typeof id === 'number') setProgressMap((m) => ({ ...m, [id]: progress }));
|
const [task, setTask] = useState<Task | null>(null);
|
||||||
};
|
const [loading, setLoading] = useState(true);
|
||||||
window.addEventListener('queue-progress', onProg);
|
const [error, setError] = useState<string | null>(null);
|
||||||
return () => window.removeEventListener('queue-progress', onProg);
|
const [uploading, setUploading] = useState(false);
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
// Camera state
|
||||||
const files = Array.from(e.target.files ?? []).slice(0, 10);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const results: Item[] = [];
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
for (const f of files) {
|
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 {
|
try {
|
||||||
const out = await compressPhoto(f, { targetBytes: 1_500_000, maxEdge: 2560, qualityStart: 0.85 });
|
setLoading(true);
|
||||||
results.push({ file: f, out, progress: 0 });
|
setError(null);
|
||||||
} catch (err: any) {
|
|
||||||
results.push({ file: f, progress: 0, error: err?.message || 'Komprimierung fehlgeschlagen' });
|
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() {
|
if (error || !task) {
|
||||||
// Enqueue items for offline-friendly processing
|
return (
|
||||||
for (const it of items) {
|
<Page title="Fehler">
|
||||||
await queue.add({ slug: slug!, fileName: it.out?.name ?? it.file.name, blob: it.out ?? it.file });
|
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 ${
|
||||||
}
|
isDark ? 'text-white' : 'text-gray-900'
|
||||||
setItems([]);
|
}`}>
|
||||||
|
<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 (
|
return (
|
||||||
<Page title="Foto aufnehmen/hochladen">
|
<Page title={task.title}>
|
||||||
<Input type="file" accept="image/*" multiple capture="environment" onChange={onPick} />
|
<div className={`min-h-screen flex flex-col ${
|
||||||
<div className="h-3" />
|
isDark ? 'bg-gray-900 text-white' : 'bg-black text-white'
|
||||||
<Button onClick={startUpload} disabled={items.length === 0}>Hochladen</Button>
|
}`}>
|
||||||
<div className="mt-4 space-y-2">
|
{/* Camera Preview Container */}
|
||||||
{items.map((it, i) => (
|
<div className="flex-1 relative overflow-hidden">
|
||||||
<div key={i} className="rounded border p-2 text-sm">
|
{/* Video Background */}
|
||||||
<div className="flex items-center justify-between">
|
<video
|
||||||
<div className="truncate">{it.file.name}</div>
|
ref={videoRef}
|
||||||
<div className="text-xs text-muted-foreground">
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
{formatBytes(it.out?.size ?? it.file.size)}
|
playsInline
|
||||||
</div>
|
muted
|
||||||
</div>
|
/>
|
||||||
{it.done && <div className="text-xs text-muted-foreground">Fertig</div>}
|
|
||||||
{it.error && <div className="text-xs text-red-500">{it.error}</div>}
|
{/* Task Info Overlay */}
|
||||||
</div>
|
<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">
|
||||||
</div>
|
<h1 className="text-xl font-bold">{task.title}</h1>
|
||||||
<div className="mt-6">
|
<p className="text-sm leading-relaxed opacity-90">{task.description}</p>
|
||||||
<div className="mb-2 text-sm font-medium">Warteschlange</div>
|
{task.instructions && (
|
||||||
{queue.loading ? (
|
<div className="text-xs italic opacity-80 mt-2 pt-2 border-t border-white/20">
|
||||||
<div className="text-sm text-muted-foreground">Lade…</div>
|
💡 {task.instructions}
|
||||||
) : 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>
|
|
||||||
</div>
|
</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="flex items-center justify-between pt-2">
|
||||||
<div className="h-2 rounded bg-emerald-500" style={{ width: `${progressMap[q.id] ?? 0}%` }} />
|
<span className={`text-xs font-medium ${difficultyColor}`}>
|
||||||
</div>
|
Schwierigkeit: {task.difficulty}
|
||||||
|
</span>
|
||||||
|
{emotionSlug && (
|
||||||
|
<span className="text-xs opacity-80">
|
||||||
|
Stimmung: {task.emotion?.name || emotionSlug}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</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>
|
</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>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,20 +10,69 @@ export function usePollGalleryDelta(slug: string) {
|
|||||||
const timer = useRef<number | null>(null);
|
const timer = useRef<number | null>(null);
|
||||||
|
|
||||||
async function fetchDelta() {
|
async function fetchDelta() {
|
||||||
const qs = latestAt.current ? `?since=${encodeURIComponent(latestAt.current)}` : '';
|
try {
|
||||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/photos${qs}`, {
|
const qs = latestAt.current ? `?since=${encodeURIComponent(latestAt.current)}` : '';
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/photos${qs}`, {
|
||||||
});
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
if (res.status === 304) return;
|
});
|
||||||
const json = await res.json();
|
|
||||||
if (Array.isArray(json.data)) {
|
if (res.status === 304) return; // No new content
|
||||||
const added = json.data.length;
|
|
||||||
const merged = latestAt.current ? [...json.data, ...photos] : json.data;
|
if (!res.ok) {
|
||||||
if (added > 0 && latestAt.current) setNewCount((c) => c + added);
|
console.warn(`Gallery API error: ${res.status} ${res.statusText}`);
|
||||||
setPhotos(merged);
|
return; // Don't update state on error
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
// Handle different response formats
|
||||||
|
const newPhotos = Array.isArray(json.data) ? json.data :
|
||||||
|
Array.isArray(json) ? json :
|
||||||
|
json.photos || [];
|
||||||
|
|
||||||
|
if (newPhotos.length > 0) {
|
||||||
|
const added = newPhotos.length;
|
||||||
|
|
||||||
|
if (latestAt.current) {
|
||||||
|
// Delta mode: Add new photos to existing list
|
||||||
|
const merged = [...newPhotos, ...photos];
|
||||||
|
// Remove duplicates by ID
|
||||||
|
const uniquePhotos = merged.filter((photo, index, self) =>
|
||||||
|
index === self.findIndex(p => p.id === photo.id)
|
||||||
|
);
|
||||||
|
setPhotos(uniquePhotos);
|
||||||
|
if (added > 0) setNewCount((c) => c + added);
|
||||||
|
} else {
|
||||||
|
// Initial load: Set all photos
|
||||||
|
setPhotos(newPhotos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update latest timestamp
|
||||||
|
if (json.latest_photo_at) {
|
||||||
|
latestAt.current = json.latest_photo_at;
|
||||||
|
} else if (newPhotos.length > 0) {
|
||||||
|
// Fallback: use newest photo timestamp
|
||||||
|
const newest = newPhotos.reduce((latest: number, photo: any) => {
|
||||||
|
const photoTime = new Date(photo.created_at || photo.created_at_timestamp || 0).getTime();
|
||||||
|
return photoTime > latest ? photoTime : latest;
|
||||||
|
}, 0);
|
||||||
|
latestAt.current = new Date(newest).toISOString();
|
||||||
|
}
|
||||||
|
} else if (latestAt.current) {
|
||||||
|
// Delta mode but no new photos: keep existing photos
|
||||||
|
console.log('No new photos, keeping existing gallery state');
|
||||||
|
// Don't update photos state
|
||||||
|
} else {
|
||||||
|
// Initial load with no photos
|
||||||
|
setPhotos([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Gallery polling error:', error);
|
||||||
|
setLoading(false);
|
||||||
|
// Don't update state on error - keep previous photos
|
||||||
}
|
}
|
||||||
if (json.latest_photo_at) latestAt.current = json.latest_photo_at;
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function HomeLayout() {
|
|||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
return (
|
return (
|
||||||
<div className="pb-16">
|
<div className="pb-16">
|
||||||
<Header title={slug ? `Event: ${slug}` : 'Fotospiel'} />
|
{slug ? <Header slug={slug} /> : <Header title="Event" />}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
42
resources/js/guest/services/eventApi.ts
Normal file
42
resources/js/guest/services/eventApi.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { getDeviceId } from '../lib/device';
|
||||||
|
|
||||||
|
export interface EventData {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
default_locale: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
type?: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventStats {
|
||||||
|
onlineGuests: number;
|
||||||
|
tasksSolved: number;
|
||||||
|
latestPhotoAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEvent(slug: string): Promise<EventData> {
|
||||||
|
const res = await fetch(`/api/v1/events/${slug}`);
|
||||||
|
if (!res.ok) throw new Error('Event fetch failed');
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStats(slug: string): Promise<EventStats> {
|
||||||
|
const res = await fetch(`/api/v1/events/${slug}/stats`, {
|
||||||
|
headers: {
|
||||||
|
'X-Device-Id': getDeviceId(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Stats fetch failed');
|
||||||
|
const json = await res.json();
|
||||||
|
return {
|
||||||
|
onlineGuests: json.onlineGuests ?? 0,
|
||||||
|
tasksSolved: json.tasksSolved ?? 0,
|
||||||
|
latestPhotoAt: json.latestPhotoAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,15 +1,102 @@
|
|||||||
import { getDeviceId } from '../lib/device';
|
import { getDeviceId } from '../lib/device';
|
||||||
|
|
||||||
|
function getCsrfToken(): string | null {
|
||||||
|
// Method 1: Meta tag (preferred for SPA)
|
||||||
|
const metaToken = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (metaToken) {
|
||||||
|
return (metaToken as HTMLMetaElement).getAttribute('content') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: XSRF-TOKEN cookie (Sanctum fallback)
|
||||||
|
const name = 'XSRF-TOKEN=';
|
||||||
|
const decodedCookie = decodeURIComponent(document.cookie);
|
||||||
|
const ca = decodedCookie.split(';');
|
||||||
|
for (let i = 0; i < ca.length; i++) {
|
||||||
|
let c = ca[i].trimStart();
|
||||||
|
if (c.startsWith(name)) {
|
||||||
|
const token = c.substring(name.length);
|
||||||
|
try {
|
||||||
|
// Decode base64 if needed
|
||||||
|
return decodeURIComponent(atob(token));
|
||||||
|
} catch {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('No CSRF token found - API requests may fail');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCsrfHeaders(): Record<string, string> {
|
||||||
|
const token = getCsrfToken();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'X-Device-Id': getDeviceId(),
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['X-CSRF-TOKEN'] = token;
|
||||||
|
headers['X-XSRF-TOKEN'] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
export async function likePhoto(id: number): Promise<number> {
|
export async function likePhoto(id: number): Promise<number> {
|
||||||
|
const headers = getCsrfHeaders();
|
||||||
|
|
||||||
const res = await fetch(`/api/v1/photos/${id}/like`, {
|
const res = await fetch(`/api/v1/photos/${id}/like`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'X-Device-Id': getDeviceId(),
|
...headers,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('like failed');
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
if (res.status === 419) {
|
||||||
|
throw new Error('CSRF Token mismatch. This usually means:\n\n' +
|
||||||
|
'1. The page needs to be refreshed\n' +
|
||||||
|
'2. Check if <meta name="csrf-token"> is present in HTML source\n' +
|
||||||
|
'3. API routes might need CSRF exemption in VerifyCsrfToken middleware');
|
||||||
|
}
|
||||||
|
throw new Error(`Like failed: ${res.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
return json.likes_count ?? 0;
|
return json.likes_count ?? json.data?.likes_count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadPhoto(slug: string, file: File, taskId?: number, emotionSlug?: string): Promise<number> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('photo', file, `photo-${Date.now()}.jpg`);
|
||||||
|
if (taskId) formData.append('task_id', taskId.toString());
|
||||||
|
if (emotionSlug) formData.append('emotion_slug', emotionSlug);
|
||||||
|
formData.append('device_id', getDeviceId());
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/events/${slug}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
// Don't set Content-Type for FormData - let browser handle it with boundary
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
if (res.status === 419) {
|
||||||
|
throw new Error('CSRF Token mismatch during upload.\n\n' +
|
||||||
|
'This usually means:\n' +
|
||||||
|
'1. API routes need CSRF exemption in VerifyCsrfToken middleware\n' +
|
||||||
|
'2. Check if <meta name="csrf-token"> is present in page source\n' +
|
||||||
|
'3. The page might need to be refreshed');
|
||||||
|
}
|
||||||
|
throw new Error(`Upload failed: ${res.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
return json.photo_id ?? json.id ?? json.data?.id ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{ config('app.name', 'Fotospiel') }}</title>
|
<title>{{ config('app.name', 'Fotospiel') }}</title>
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
@vite('resources/js/guest/main.tsx')
|
@vite('resources/js/guest/main.tsx')
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
20
routes/api.php
Normal file
20
routes/api.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Http\Controllers\Api\EventPublicController;
|
||||||
|
|
||||||
|
// API routes without CSRF protection for guest PWA (stateless)
|
||||||
|
Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||||
|
// GET routes (read-only)
|
||||||
|
Route::get('/events/{slug}', [EventPublicController::class, 'event'])->name('events.show');
|
||||||
|
Route::get('/events/{slug}/stats', [EventPublicController::class, 'stats'])->name('events.stats');
|
||||||
|
Route::get('/events/{slug}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions');
|
||||||
|
Route::get('/events/{slug}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks');
|
||||||
|
Route::get('/events/{slug}/photos', [EventPublicController::class, 'photos'])->name('events.photos');
|
||||||
|
Route::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show');
|
||||||
|
|
||||||
|
// POST routes without CSRF (guest PWA, stateless)
|
||||||
|
Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like');
|
||||||
|
Route::post('/events/{slug}/upload', [EventPublicController::class, 'upload'])->name('events.upload');
|
||||||
|
});
|
||||||
@@ -22,13 +22,6 @@ Route::view('/legal/{any?}', 'guest')->where('any', '.*');
|
|||||||
|
|
||||||
// Minimal public API for Guest PWA (stateless; no CSRF)
|
// Minimal public API for Guest PWA (stateless; no CSRF)
|
||||||
Route::prefix('api/v1')->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class])->group(function () {
|
Route::prefix('api/v1')->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class])->group(function () {
|
||||||
Route::get('/events/{slug}/stats', [\App\Http\Controllers\Api\EventPublicController::class, 'stats'])->middleware('throttle:60,1');
|
|
||||||
Route::get('/events/{slug}/photos', [\App\Http\Controllers\Api\EventPublicController::class, 'photos'])->middleware('throttle:60,1');
|
|
||||||
Route::get('/events/{slug}', [\App\Http\Controllers\Api\EventPublicController::class, 'event'])->middleware('throttle:60,1');
|
|
||||||
Route::get('/photos/{id}', [\App\Http\Controllers\Api\EventPublicController::class, 'photo'])->whereNumber('id')->middleware('throttle:120,1');
|
|
||||||
Route::post('/photos/{id}/like', [\App\Http\Controllers\Api\EventPublicController::class, 'like'])->whereNumber('id')->middleware('throttle:60,1');
|
|
||||||
Route::post('/events/{slug}/photos', [\App\Http\Controllers\Api\EventPublicController::class, 'upload'])->middleware('throttle:30,1');
|
|
||||||
|
|
||||||
// Public legal pages
|
// Public legal pages
|
||||||
Route::get('/legal/{slug}', [\App\Http\Controllers\Api\LegalController::class, 'show']);
|
Route::get('/legal/{slug}', [\App\Http\Controllers\Api\LegalController::class, 'show']);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user