From ae9b9160acf5acf68da1090b4885fc0074ef3ed3 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 17 Oct 2025 23:24:06 +0200 Subject: [PATCH] - Added public gallery API with token-expiry enforcement, branding payload, cursor pagination, and per-photo download stream (app/Http/Controllers/Api/EventPublicController.php:1, routes/api.php:16). 410 is returned when the package gallery duration has lapsed. - Served the guest PWA at /g/{token} and introduced a mobile-friendly gallery page with lazy-loaded thumbnails, themed colors, lightbox, and download links plus new gallery data client (resources/js/guest/pages/PublicGalleryPage.tsx:1, resources/js/guest/services/galleryApi.ts:1, resources/js/guest/router.tsx:1). Added i18n strings for the public gallery experience (resources/js/guest/i18n/messages.ts:1). - Ensured checkout step changes snap back to the progress bar on mobile via smooth scroll anchoring (resources/ js/pages/marketing/checkout/CheckoutWizard.tsx:1). - Enabled tenant admins to export all approved event photos through a new download action that streams a ZIP archive, with translations and routing in place (app/Http/Controllers/Tenant/EventPhotoArchiveController.php:1, app/Filament/Resources/EventResource.php:1, routes/web.php:1, resources/lang/de/admin.php:1, resources/lang/en/admin.php:1). --- .dockerignore | 17 + Dockerfile | 98 ++++++ app/Filament/Resources/EventResource.php | 6 + app/Filament/Resources/UserResource.php | 2 +- .../Controllers/Api/EventPublicController.php | 288 +++++++++++++++ .../Tenant/EventPhotoArchiveController.php | 132 +++++++ docker-compose.yml | 137 +++++++ docs/deployment/docker.md | 109 ++++++ docs/queue-supervisor/README.md | 103 ++++++ docs/queue-supervisor/horizon.sh | 11 + docs/queue-supervisor/queue-worker.sh | 27 ++ resources/js/guest/i18n/messages.ts | 26 ++ .../js/guest/pages/PublicGalleryPage.tsx | 333 ++++++++++++++++++ resources/js/guest/router.tsx | 2 + resources/js/guest/services/galleryApi.ts | 81 +++++ .../marketing/checkout/CheckoutWizard.tsx | 26 +- resources/lang/de/admin.php | 1 + resources/lang/en/admin.php | 1 + routes/api.php | 6 + routes/web.php | 7 + 20 files changed, 1410 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 app/Http/Controllers/Tenant/EventPhotoArchiveController.php create mode 100644 docker-compose.yml create mode 100644 docs/deployment/docker.md create mode 100644 docs/queue-supervisor/README.md create mode 100644 docs/queue-supervisor/horizon.sh create mode 100644 docs/queue-supervisor/queue-worker.sh create mode 100644 resources/js/guest/pages/PublicGalleryPage.tsx create mode 100644 resources/js/guest/services/galleryApi.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..582124c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +vendor +storage/framework/cache/*.php +storage/framework/sessions/* +storage/framework/testing/* +storage/logs/* +public/hot +public/storage +.git +.gitignore +.env +.env.* +npm-debug.log +yarn-error.log +docker-compose.override.yml +docs/queue-supervisor/*.log + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a52ac15 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,98 @@ +# syntax=docker/dockerfile:1.6 + +ARG PHP_VERSION=8.3 +ARG NODE_VERSION=20 + +################################################################################ +# Node build stage - compile front-end assets +################################################################################ +FROM node:${NODE_VERSION}-bookworm AS node_builder + +WORKDIR /var/www/html + +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --prefer-offline + +COPY . . +RUN npm run build + +################################################################################ +# Composer dependencies +################################################################################ +FROM composer:2 AS vendor + +WORKDIR /var/www/html + +COPY composer.json composer.lock ./ + +# Install production dependencies only, skip scripts (they run at runtime) +RUN composer install \ + --no-dev \ + --no-scripts \ + --no-interaction \ + --prefer-dist \ + --optimize-autoloader + +################################################################################ +# PHP-FPM runtime image +################################################################################ +FROM php:${PHP_VERSION}-fpm-bullseye AS app + +ARG UID=1000 +ARG GID=1000 + +ENV APP_ENV=production \ + APP_DEBUG=false \ + PHP_OPCACHE_VALIDATE_TIMESTAMPS=0 + +WORKDIR /opt/app + +# Install system dependencies & PHP extensions +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + curl \ + libjpeg62-turbo-dev \ + libpng-dev \ + libfreetype6-dev \ + libzip-dev \ + libonig-dev \ + libicu-dev \ + libxml2-dev \ + unzip \ + nano \ + rsync \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j$(nproc) \ + bcmath \ + exif \ + gd \ + intl \ + opcache \ + pcntl \ + pdo_mysql \ + zip \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Copy application source +COPY . . + +# Copy vendor dependencies and build artifacts +COPY --from=vendor /var/www/html/vendor ./vendor +COPY --from=node_builder /var/www/html/public/build ./public/build + +# Copy production php.ini overrides if present +COPY docker/php/php.ini /usr/local/etc/php/conf.d/app.ini +COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini + +# Entrypoint prepares deployment directory on persistent volume +COPY docker/app/entrypoint.sh /usr/local/bin/app-entrypoint +RUN chmod +x /usr/local/bin/app-entrypoint + +EXPOSE 9000 + +ENTRYPOINT ["app-entrypoint"] +CMD ["php-fpm"] diff --git a/app/Filament/Resources/EventResource.php b/app/Filament/Resources/EventResource.php index 6023ddf..4ab3618 100644 --- a/app/Filament/Resources/EventResource.php +++ b/app/Filament/Resources/EventResource.php @@ -134,6 +134,12 @@ class EventResource extends Resource ->label(__('admin.events.actions.toggle_active')) ->icon('heroicon-o-power') ->action(fn ($record) => $record->update(['is_active' => ! $record->is_active])), + Actions\Action::make('download_photos') + ->label(__('admin.events.actions.download_photos')) + ->icon('heroicon-o-arrow-down-tray') + ->url(fn (Event $record) => route('tenant.events.photos.archive', $record)) + ->openUrlInNewTab() + ->visible(fn (Event $record) => $record->photos()->where('status', 'approved')->exists()), Actions\Action::make('join_tokens') ->label(__('admin.events.actions.join_link_qr')) ->icon('heroicon-o-qr-code') diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index f3eedd8..04e3c54 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -10,7 +10,7 @@ use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; -use Filament\Forms\Components\Section; +use Filament\Schemas\Components\Section; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 715c3f1..4a48324 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use Carbon\Carbon; use Carbon\CarbonImmutable; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; @@ -17,6 +18,9 @@ use App\Services\Storage\EventStorageManager; use App\Models\Event; use App\Models\EventJoinToken; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Arr; +use App\Models\Photo; +use App\Models\EventMediaAsset; class EventPublicController extends BaseController { @@ -103,6 +107,45 @@ class EventPublicController extends BaseController return [$event, $joinToken]; } + /** + * @return JsonResponse|array{0: Event, 1: EventJoinToken} + */ + private function resolveGalleryEvent(Request $request, string $token): JsonResponse|array + { + $result = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($result instanceof JsonResponse) { + return $result; + } + + [$eventRecord, $joinToken] = $result; + + $event = Event::with(['tenant', 'eventPackage.package'])->find($eventRecord->id); + + if (! $event) { + return response()->json([ + 'error' => [ + 'code' => 'event_not_found', + 'message' => 'The event associated with this gallery could not be located.', + ], + ], Response::HTTP_NOT_FOUND); + } + + $expiresAt = optional($event->eventPackage)->gallery_expires_at; + + if ($expiresAt instanceof Carbon && $expiresAt->isPast()) { + return response()->json([ + 'error' => [ + 'code' => 'gallery_expired', + 'message' => 'The gallery is no longer available for this event.', + 'expired_at' => $expiresAt->toIso8601String(), + ], + ], Response::HTTP_GONE); + } + + return [$event, $joinToken]; + } + private function handleTokenFailure(Request $request, string $rateLimiterKey, string $code, int $status, array $context = []): JsonResponse { if (RateLimiter::tooManyAttempts($rateLimiterKey, 10)) { @@ -175,6 +218,251 @@ class EventPublicController extends BaseController return $path; // fallback as-is } + + private function translateLocalized(string|array|null $value, string $locale, string $fallback = ''): string + { + if (is_array($value)) { + $primary = $value[$locale] ?? $value['de'] ?? $value['en'] ?? null; + + return $primary ?? (reset($value) ?: $fallback); + } + + if (is_string($value) && $value !== '') { + return $value; + } + + return $fallback; + } + + private function buildGalleryBranding(Event $event): array + { + $defaultPrimary = '#f43f5e'; + $defaultSecondary = '#fb7185'; + $defaultBackground = '#ffffff'; + + $eventBranding = Arr::get($event->settings, 'branding', []); + $tenantBranding = Arr::get($event->tenant?->settings, 'branding', []); + + return [ + 'primary_color' => Arr::get($eventBranding, 'primary_color') + ?? Arr::get($tenantBranding, 'primary_color') + ?? $defaultPrimary, + 'secondary_color' => Arr::get($eventBranding, 'secondary_color') + ?? Arr::get($tenantBranding, 'secondary_color') + ?? $defaultSecondary, + 'background_color' => Arr::get($eventBranding, 'background_color') + ?? Arr::get($tenantBranding, 'background_color') + ?? $defaultBackground, + ]; + } + + private function encodeGalleryCursor(Photo $photo): string + { + return base64_encode(json_encode([ + 'id' => $photo->id, + 'created_at' => $photo->created_at?->toIso8601String(), + ])); + } + + private function decodeGalleryCursor(?string $cursor): ?array + { + if (! $cursor) { + return null; + } + + $decoded = json_decode(base64_decode($cursor, true) ?: '', true); + + if (! is_array($decoded) || ! isset($decoded['id'], $decoded['created_at'])) { + return null; + } + + try { + return [ + 'id' => (int) $decoded['id'], + 'created_at' => Carbon::parse($decoded['created_at']), + ]; + } catch (\Throwable $e) { + return null; + } + } + + private function makeGalleryPhotoResource(Photo $photo, string $token): array + { + $thumbnail = $this->toPublicUrl($photo->thumbnail_path ?? null) ?? $this->toPublicUrl($photo->file_path ?? null); + $full = $this->toPublicUrl($photo->file_path ?? null); + + return [ + 'id' => $photo->id, + 'thumbnail_url' => $thumbnail, + 'full_url' => $full, + 'download_url' => route('api.v1.gallery.photos.download', [ + 'token' => $token, + 'photo' => $photo->id, + ]), + 'likes_count' => $photo->likes_count, + 'guest_name' => $photo->guest_name, + 'created_at' => $photo->created_at?->toIso8601String(), + ]; + } + + public function gallery(Request $request, string $token) + { + $locale = $request->query('locale', app()->getLocale()); + + $resolved = $this->resolveGalleryEvent($request, $token); + + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + /** @var array{0: Event, 1: EventJoinToken} $resolved */ + [$event] = $resolved; + + $branding = $this->buildGalleryBranding($event); + $expiresAt = optional($event->eventPackage)->gallery_expires_at; + + return response()->json([ + 'event' => [ + 'id' => $event->id, + 'name' => $this->translateLocalized($event->name, $locale, 'Fotospiel Event'), + 'slug' => $event->slug, + 'description' => $this->translateLocalized($event->description, $locale, ''), + 'gallery_expires_at' => $expiresAt?->toIso8601String(), + ], + 'branding' => $branding, + ]); + } + + public function galleryPhotos(Request $request, string $token) + { + $resolved = $this->resolveGalleryEvent($request, $token); + + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + /** @var array{0: Event, 1: EventJoinToken} $resolved */ + [$event] = $resolved; + + $limit = (int) $request->query('limit', 30); + $limit = max(1, min($limit, 60)); + + $cursor = $this->decodeGalleryCursor($request->query('cursor')); + + $query = Photo::query() + ->where('event_id', $event->id) + ->where('status', 'approved') + ->orderByDesc('created_at') + ->orderByDesc('id'); + + if ($cursor) { + /** @var Carbon $cursorTime */ + $cursorTime = $cursor['created_at']; + $cursorId = $cursor['id']; + + $query->where(function ($inner) use ($cursorTime, $cursorId) { + $inner->where('created_at', '<', $cursorTime) + ->orWhere(function ($nested) use ($cursorTime, $cursorId) { + $nested->where('created_at', '=', $cursorTime) + ->where('id', '<', $cursorId); + }); + }); + } + + $photos = $query->limit($limit + 1)->get(); + + $hasMore = $photos->count() > $limit; + $items = $photos->take($limit); + + $nextCursor = null; + + if ($hasMore) { + $cursorPhoto = $photos->slice($limit, 1)->first(); + if ($cursorPhoto instanceof Photo) { + $nextCursor = $this->encodeGalleryCursor($cursorPhoto); + } + } + + return response()->json([ + 'data' => $items->map(fn (Photo $photo) => $this->makeGalleryPhotoResource($photo, $token))->all(), + 'next_cursor' => $nextCursor, + ]); + } + + public function galleryPhotoDownload(Request $request, string $token, int $photo) + { + $resolved = $this->resolveGalleryEvent($request, $token); + + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + /** @var array{0: Event, 1: EventJoinToken} $resolved */ + [$event] = $resolved; + + $record = Photo::with('mediaAsset') + ->where('id', $photo) + ->where('event_id', $event->id) + ->where('status', 'approved') + ->first(); + + if (! $record) { + return response()->json([ + 'error' => [ + 'code' => 'photo_not_found', + 'message' => 'The requested photo is no longer available.', + ], + ], Response::HTTP_NOT_FOUND); + } + + $asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first(); + + if ($asset) { + $disk = $asset->disk ?? config('filesystems.default'); + $path = $asset->path ?? $record->file_path; + + try { + if ($path && Storage::disk($disk)->exists($path)) { + $stream = Storage::disk($disk)->readStream($path); + + if ($stream) { + $extension = pathinfo($path, PATHINFO_EXTENSION) ?: 'jpg'; + $filename = sprintf('fotospiel-event-%s-photo-%s.%s', $event->id, $record->id, $extension); + $mime = $asset->mime_type ?? 'image/jpeg'; + + return response()->streamDownload(function () use ($stream) { + fpassthru($stream); + fclose($stream); + }, $filename, [ + 'Content-Type' => $mime, + ]); + } + } + } catch (\Throwable $e) { + Log::warning('Gallery photo download failed', [ + 'event_id' => $event->id, + 'photo_id' => $record->id, + 'disk' => $asset->disk ?? null, + 'path' => $asset->path ?? null, + 'error' => $e->getMessage(), + ]); + } + } + + $publicUrl = $this->toPublicUrl($record->file_path ?? null); + + if ($publicUrl) { + return redirect()->away($publicUrl); + } + + return response()->json([ + 'error' => [ + 'code' => 'photo_unavailable', + 'message' => 'The requested photo could not be downloaded.', + ], + ], Response::HTTP_NOT_FOUND); + } + public function event(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, [ diff --git a/app/Http/Controllers/Tenant/EventPhotoArchiveController.php b/app/Http/Controllers/Tenant/EventPhotoArchiveController.php new file mode 100644 index 0000000..32fb71e --- /dev/null +++ b/app/Http/Controllers/Tenant/EventPhotoArchiveController.php @@ -0,0 +1,132 @@ +tenant_id !== (int) $event->tenant_id) { + abort(403, 'Unauthorized'); + } + + $photos = Photo::query() + ->with('mediaAsset') + ->where('event_id', $event->id) + ->where('status', 'approved') + ->orderBy('created_at') + ->get(); + + if ($photos->isEmpty()) { + abort(404, 'No approved photos available for this event.'); + } + + $zip = new ZipArchive(); + $tempPath = tempnam(sys_get_temp_dir(), 'fotospiel-photos-'); + + if ($tempPath === false || $zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + abort(500, 'Unable to generate archive.'); + } + + foreach ($photos as $photo) { + $filename = $this->buildFilename($event, $photo); + + $asset = $photo->mediaAsset ?? EventMediaAsset::query() + ->where('photo_id', $photo->id) + ->where('variant', 'original') + ->first(); + + $added = false; + + if ($asset && $asset->path) { + $added = $this->addDiskFileToArchive($zip, $asset->disk ?? config('filesystems.default'), $asset->path, $filename); + } + + if (! $added && $photo->file_path) { + $added = $this->addDiskFileToArchive($zip, config('filesystems.default'), $photo->file_path, $filename); + } + + if (! $added) { + Log::warning('Skipping photo in archive build', [ + 'event_id' => $event->id, + 'photo_id' => $photo->id, + ]); + } + } + + $zip->close(); + + $downloadName = sprintf('fotospiel-event-%s-photos.zip', $event->slug ?? $event->id); + + return response()->streamDownload(function () use ($tempPath) { + $stream = fopen($tempPath, 'rb'); + + if (! $stream) { + throw new FileNotFoundException('Archive could not be opened.'); + } + + fpassthru($stream); + fclose($stream); + unlink($tempPath); + }, $downloadName, [ + 'Content-Type' => 'application/zip', + ]); + } + + private function buildFilename(Event $event, Photo $photo): string + { + $timestamp = $photo->created_at?->format('Ymd_His') ?? now()->format('Ymd_His'); + $extension = pathinfo($photo->file_path ?? '', PATHINFO_EXTENSION) ?: 'jpg'; + + return sprintf('%s-photo-%d.%s', $timestamp, $photo->id, $extension); + } + + private function addDiskFileToArchive(ZipArchive $zip, ?string $diskName, string $path, string $filename): bool + { + $disk = $diskName ? Storage::disk($diskName) : Storage::disk(config('filesystems.default')); + + try { + if (method_exists($disk, 'path')) { + $absolute = $disk->path($path); + + if (is_file($absolute)) { + return $zip->addFile($absolute, $filename); + } + } + + $stream = $disk->readStream($path); + + if ($stream) { + $contents = stream_get_contents($stream); + fclose($stream); + + if ($contents !== false) { + return $zip->addFromString($filename, $contents); + } + } + } catch (\Throwable $e) { + Log::warning('Failed to add file to archive', [ + 'disk' => $diskName, + 'path' => $path, + 'error' => $e->getMessage(), + ]); + } + + return false; + } +} + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..13e37b1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,137 @@ +version: "3.9" + +services: + app: + build: + context: . + dockerfile: Dockerfile + args: + PHP_VERSION: 8.3 + NODE_VERSION: 20 + image: fotospiel-app:latest + env_file: + - docker/.env.docker + environment: + APP_ENV: ${APP_ENV:-production} + APP_DEBUG: ${APP_DEBUG:-false} + APP_SOURCE: /opt/app + APP_TARGET: /var/www/html + APP_USER: www-data + APP_GROUP: www-data + volumes: + - app-code:/var/www/html + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped + + web: + image: nginx:1.25-alpine + depends_on: + - app + volumes: + - app-code:/var/www/html:ro + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + ports: + - "${APP_HTTP_PORT:-8080}:80" + restart: unless-stopped + + queue: + image: fotospiel-app:latest + command: /var/www/html/docs/queue-supervisor/queue-worker.sh default + env_file: + - docker/.env.docker + environment: + APP_ENV: ${APP_ENV:-production} + volumes: + - app-code:/var/www/html + depends_on: + - app + - redis + restart: unless-stopped + + media-storage-worker: + image: fotospiel-app:latest + command: /var/www/html/docs/queue-supervisor/queue-worker.sh media-storage + env_file: + - docker/.env.docker + environment: + APP_ENV: ${APP_ENV:-production} + QUEUE_TRIES: 5 + QUEUE_SLEEP: 5 + volumes: + - app-code:/var/www/html + depends_on: + - app + - redis + restart: unless-stopped + + scheduler: + image: fotospiel-app:latest + command: php artisan schedule:work + env_file: + - docker/.env.docker + environment: + APP_ENV: ${APP_ENV:-production} + volumes: + - app-code:/var/www/html + depends_on: + - app + restart: unless-stopped + + horizon: + image: fotospiel-app:latest + command: /var/www/html/docs/queue-supervisor/horizon.sh + env_file: + - docker/.env.docker + environment: + APP_ENV: ${APP_ENV:-production} + volumes: + - app-code:/var/www/html + depends_on: + - app + - redis + restart: unless-stopped + profiles: ["horizon"] + + redis: + image: redis:7.4-alpine + command: ["redis-server", "--save", "", "--appendonly", "no"] + volumes: + - redis-data:/data + ports: + - "${APP_REDIS_PORT:-6379}:6379" + restart: unless-stopped + + mysql: + image: mysql:8.4 + environment: + MYSQL_DATABASE: ${DB_DATABASE:-fotospiel} + MYSQL_USER: ${DB_USERNAME:-fotospiel} + MYSQL_PASSWORD: ${DB_PASSWORD:-secret} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-secret-root} + volumes: + - mysql-data:/var/lib/mysql + ports: + - "${APP_DB_PORT:-3306}:3306" + healthcheck: + test: + [ + "CMD", + "mysqladmin", + "ping", + "-h", + "127.0.0.1", + "-p${DB_PASSWORD:-secret}" + ] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + app-code: + mysql-data: + redis-data: diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md new file mode 100644 index 0000000..2fcc0b5 --- /dev/null +++ b/docs/deployment/docker.md @@ -0,0 +1,109 @@ +# Docker Deployment Guide + +This guide describes the recommended, repeatable way to run the Fotospiel platform in Docker for production or high-fidelity staging environments. It pairs a multi-stage build (PHP-FPM + asset pipeline) with a Compose stack that includes Nginx, worker processes, Redis, and MySQL. + +## 1. Prerequisites + +- Docker Engine 24+ and Docker Compose v2. +- A `.env` file for the application (see step 4). +- Optional: an external MySQL/Redis if you do not want to run the bundled containers. + +## 2. Build the application image + +```bash +docker compose build app +``` + +The build performs the following steps: + +1. Installs Node dependencies and runs `npm run build` to produce production assets. +2. Installs PHP dependencies with Composer (`--no-dev --no-scripts`). +3. Creates a PHP 8.3 FPM image with required extensions (GD, intl, Redis, etc.). +4. Stores the compiled application under `/opt/app`; the runtime entrypoint syncs it into the shared volume when a container starts. + +## 3. Configure environment + +Copy the sample Docker environment file and edit the secrets: + +```bash +cp docker/.env.docker docker/.env.docker.local +``` + +Set (at minimum): + +- `APP_KEY` — generate with `docker compose run --rm app php artisan key:generate --show`. +- Database credentials (`DB_*`). The provided MySQL service defaults to `fotospiel/secret`. +- `STORAGE_ALERT_EMAIL` — recipient for upload failure alerts (optional). + +Point `docker-compose.yml` to the file you created by either renaming it to `docker/.env.docker` or adjusting the `env_file` entries. + +## 4. Boot the stack + +```bash +docker compose up -d +``` + +Services started: + +- `app`: PHP-FPM container serving the Laravel application. +- `web`: Nginx proxy forwarding requests to PHP-FPM on port `8080` by default (`APP_HTTP_PORT`). +- `queue` & `media-storage-worker`: queue consumers (default + media archival). +- `scheduler`: runs `php artisan schedule:work`. +- `horizon` (optional, disabled unless `--profile horizon` is supplied). +- `redis` & `mysql`. + +### Migrations & seeds + +Run once after the first boot or when deploying new schema changes: + +```bash +docker compose exec app php artisan migrate --force +docker compose exec app php artisan db:seed --class=MediaStorageTargetSeeder --force +``` + +If you already have data, skip the seeder or seed only new records. + +## 5. Queue & Horizon management + +Worker entrypoints live in `docs/queue-supervisor/`. The Compose services mount the same application volume so code stays in sync. Adjust concurrency by scaling services: + +```bash +docker compose up -d --scale queue=2 --scale media-storage-worker=2 +``` + +To enable Horizon (dashboard, smart balancing): + +```bash +docker compose --profile horizon up -d horizon +``` + +The dashboard becomes available at `/horizon` and is protected by the Filament super-admin auth guard. + +## 6. Persistent data & volumes + +- `app-code` — contains the synced application, including the `storage` directory and generated assets. +- `mysql-data` — MySQL data files. +- `redis-data` — Redis persistence (disabled by default; change the Redis command if you want AOF snapshots). + +Back up the volumes before upgrades to maintain tenant media and database state. + +## 7. Updating the stack + +1. `git pull` the repository (or deploy your release branch). +2. `docker compose build app`. +3. `docker compose up -d`. +4. Run migrations + seeders if required. +5. Check logs: `docker compose logs -f app queue media-storage-worker`. + +Because the app image keeps the authoritative copy of the code, each container restart rsyncs fresh sources into the shared volume ensuring reliable updates without lingering artefacts. + +## 8. Production hardening + +- Terminate TLS with a dedicated reverse proxy (Traefik, Caddy, AWS ALB, etc.) in front of the `web` container. +- Point `APP_URL` to your public domain and enable trusted proxies. +- Externalize MySQL/Redis to managed services for better resilience. +- Configure backups for the `storage` directories and database dumps. +- Hook into your observability stack (e.g., ship container logs to Loki or ELK). + +With the provided configuration you can bootstrap a consistent Docker-based deployment across environments while keeping queue workers, migrations, and asset builds manageable. Adjust service definitions as needed for staging vs. production. + diff --git a/docs/queue-supervisor/README.md b/docs/queue-supervisor/README.md new file mode 100644 index 0000000..f91cfc2 --- /dev/null +++ b/docs/queue-supervisor/README.md @@ -0,0 +1,103 @@ +## Docker Queue & Horizon Setup + +This directory bundles ready-to-use entrypoint scripts and deployment notes for running Fotospiel’s queue workers inside Docker containers. The examples assume you already run the main application in Docker (e.g. via `docker-compose.yml`) and share the same application image for workers. + +### 1. Prepare the application image + +Make sure the worker scripts are copied into the image and marked as executable: + +```dockerfile +# Dockerfile +COPY docs/queue-supervisor /var/www/html/docs/queue-supervisor +RUN chmod +x /var/www/html/docs/queue-supervisor/*.sh +``` + +If you keep the project root mounted as a volume during development the `chmod` step can be skipped because the files will inherit host permissions. + +### 2. Queue worker containers + +Add one or more worker services to `docker-compose.yml`. The production compose file in the repo already defines `queue` and `media-storage-worker` services that call these scripts; the snippet below shows the essential pattern if you need to tweak scaling. + +```yaml +services: + queue-worker: + image: fotospiel-app # reuse the main app image + restart: unless-stopped + depends_on: + - redis # or your queue backend + environment: + APP_ENV: ${APP_ENV:-production} + QUEUE_CONNECTION: redis + QUEUE_TRIES: 3 # optional overrides + QUEUE_SLEEP: 3 + command: > + /var/www/html/docs/queue-supervisor/queue-worker.sh default + + media-storage-worker: + image: fotospiel-app + restart: unless-stopped + depends_on: + - redis + environment: + APP_ENV: ${APP_ENV:-production} + QUEUE_CONNECTION: redis + QUEUE_TRIES: 5 + QUEUE_SLEEP: 5 + command: > + /var/www/html/docs/queue-supervisor/queue-worker.sh media-storage +``` + +Scale workers by increasing `deploy.replicas` (Swarm) or adding `scale` counts (Compose v2). + +### 3. Optional: Horizon container + +If you prefer Horizon’s dashboard and auto-balancing, add another service: + +```yaml +services: + horizon: + image: fotospiel-app + restart: unless-stopped + depends_on: + - redis + environment: + APP_ENV: ${APP_ENV:-production} + QUEUE_CONNECTION: redis + command: > + /var/www/html/docs/queue-supervisor/horizon.sh +``` + +Expose Horizon via your web proxy and protect it with authentication (the app already guards `/horizon` behind the super admin panel login if configured). + +### 4. Environment variables + +- `QUEUE_CONNECTION` — should match the driver configured in `.env` (`redis` recommended). +- `QUEUE_TRIES`, `QUEUE_SLEEP`, `QUEUE_TIMEOUT`, `QUEUE_MAX_TIME` — optional tuning knobs consumed by `queue-worker.sh`. +- `STORAGE_ALERT_EMAIL` — enables upload failure notifications introduced in the new storage pipeline. +- Redis / database credentials must be available in the worker containers exactly like the web container. + +### 5. Bootstrapping reminder + +Before starting workers on a new environment: + +```bash +php artisan migrate +php artisan db:seed --class=MediaStorageTargetSeeder +``` + +Existing assets should be backfilled into `event_media_assets` with a one-off artisan command before enabling automatic archival jobs. + +### 6. Monitoring & logs + +- Containers log to STDOUT; aggregate via `docker logs` or a centralized stack. +- Horizon users can inspect `/horizon` for queue lengths and failed jobs. +- With plain workers run `php artisan queue:failed` (inside the container) to inspect failures and `php artisan queue:retry all` after resolving issues. + +### 7. Rolling updates + +When deploying new code: + +1. Build and push updated app image. +2. Run migrations & seeders. +3. Recreate worker/horizon containers: `docker compose up -d --force-recreate queue-worker media-storage-worker horizon`. +4. Tail logs to confirm workers boot cleanly and start consuming jobs. diff --git a/docs/queue-supervisor/horizon.sh b/docs/queue-supervisor/horizon.sh new file mode 100644 index 0000000..95abdce --- /dev/null +++ b/docs/queue-supervisor/horizon.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +# Horizon entrypoint for Docker containers. + +set -euo pipefail + +cd "${APP_PATH:-/var/www/html}" + +echo "[horizon] Booting Horizon..." +exec php artisan horizon + diff --git a/docs/queue-supervisor/queue-worker.sh b/docs/queue-supervisor/queue-worker.sh new file mode 100644 index 0000000..3e7b960 --- /dev/null +++ b/docs/queue-supervisor/queue-worker.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +# Generic queue worker entrypoint for Docker containers. +# Usage: queue-worker.sh [queue-name(s)] +# Example: queue-worker.sh default +# queue-worker.sh default,media-storage + +set -euo pipefail + +cd "${APP_PATH:-/var/www/html}" + +CONNECTION="${QUEUE_CONNECTION:-redis}" +QUEUES="${1:-default}" +SLEEP="${QUEUE_SLEEP:-3}" +TRIES="${QUEUE_TRIES:-3}" +TIMEOUT="${QUEUE_TIMEOUT:-60}" +MAX_TIME="${QUEUE_MAX_TIME:-0}" + +ARGS=("$CONNECTION" "--queue=${QUEUES}" "--sleep=${SLEEP}" "--tries=${TRIES}" "--timeout=${TIMEOUT}") + +if [[ "${MAX_TIME}" != "0" ]]; then + ARGS+=("--max-time=${MAX_TIME}") +fi + +echo "[queue-worker] Starting queue:work ${ARGS[*]}" +exec php artisan queue:work "${ARGS[@]}" + diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index d6d2a5c..2c689f5 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -178,6 +178,19 @@ export const messages: Record = { title: 'Nicht gefunden', description: 'Die Seite konnte nicht gefunden werden.', }, + galleryPublic: { + title: 'Galerie', + loading: 'Galerie wird geladen ...', + loadingMore: 'Weitere Fotos werden geladen', + loadError: 'Die Galerie konnte nicht geladen werden.', + loadMore: 'Mehr anzeigen', + download: 'Herunterladen', + expiredTitle: 'Galerie nicht verfügbar', + expiredDescription: 'Die Galerie für dieses Event ist abgelaufen.', + emptyTitle: 'Noch keine Fotos', + emptyDescription: 'Sobald Fotos freigegeben sind, erscheinen sie hier.', + lightboxGuestFallback: 'Gast', + }, uploadQueue: { title: 'Uploads', description: 'Warteschlange mit Fortschritt und erneuten Versuchen; Hintergrund-Sync umschalten.', @@ -510,6 +523,19 @@ export const messages: Record = { title: 'Not found', description: 'We could not find the page you requested.', }, + galleryPublic: { + title: 'Gallery', + loading: 'Loading gallery ...', + loadingMore: 'Loading more photos', + loadError: 'The gallery could not be loaded.', + loadMore: 'Show more', + download: 'Download', + expiredTitle: 'Gallery unavailable', + expiredDescription: 'The gallery for this event has expired.', + emptyTitle: 'No photos yet', + emptyDescription: 'Once photos are approved they will appear here.', + lightboxGuestFallback: 'Guest', + }, uploadQueue: { title: 'Uploads', description: 'Queue with progress/retry and background sync toggle.', diff --git a/resources/js/guest/pages/PublicGalleryPage.tsx b/resources/js/guest/pages/PublicGalleryPage.tsx new file mode 100644 index 0000000..3de7188 --- /dev/null +++ b/resources/js/guest/pages/PublicGalleryPage.tsx @@ -0,0 +1,333 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogFooter } from '@/components/ui/dialog'; +import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '../services/galleryApi'; +import { useTranslation } from '../i18n/useTranslation'; +import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages'; +import { AlertTriangle, Download, Loader2, X } from 'lucide-react'; + +interface GalleryState { + meta: GalleryMetaResponse | null; + photos: GalleryPhotoResource[]; + cursor: string | null; + loading: boolean; + loadingMore: boolean; + error: string | null; + expired: boolean; +} + +const INITIAL_STATE: GalleryState = { + meta: null, + photos: [], + cursor: null, + loading: true, + loadingMore: false, + error: null, + expired: false, +}; + +const GALLERY_PAGE_SIZE = 30; + +export default function PublicGalleryPage(): JSX.Element | null { + const { token } = useParams<{ token: string }>(); + const { t } = useTranslation(); + const [state, setState] = useState(INITIAL_STATE); + const [lightboxOpen, setLightboxOpen] = useState(false); + const [selectedPhoto, setSelectedPhoto] = useState(null); + const sentinelRef = useRef(null); + + const localeStorageKey = token ? `guestGalleryLocale_${token}` : 'guestGalleryLocale'; + const storedLocale = typeof window !== 'undefined' && token ? localStorage.getItem(localeStorageKey) : null; + const effectiveLocale = storedLocale && isLocaleCode(storedLocale as any) ? (storedLocale as any) : DEFAULT_LOCALE; + + const applyMeta = useCallback((meta: GalleryMetaResponse) => { + if (typeof window !== 'undefined' && token) { + localStorage.setItem(localeStorageKey, effectiveLocale); + } + setState((prev) => ({ + ...prev, + meta, + })); + }, [effectiveLocale, localeStorageKey, token]); + + const loadInitial = useCallback(async () => { + if (!token) { + return; + } + + setState((prev) => ({ ...prev, loading: true, error: null, expired: false, photos: [], cursor: null })); + + try { + const meta = await fetchGalleryMeta(token, effectiveLocale); + applyMeta(meta); + + const photoResponse = await fetchGalleryPhotos(token, null, GALLERY_PAGE_SIZE); + + setState((prev) => ({ + ...prev, + loading: false, + photos: photoResponse.data, + cursor: photoResponse.next_cursor, + })); + } catch (error) { + const err = error as Error & { code?: string | number }; + if (err.code === 'gallery_expired' || err.code === 410) { + setState((prev) => ({ ...prev, loading: false, expired: true })); + } else { + setState((prev) => ({ + ...prev, + loading: false, + error: err.message || t('galleryPublic.loadError'), + })); + } + } + }, [token, applyMeta, effectiveLocale, t]); + + useEffect(() => { + loadInitial(); + }, [loadInitial]); + + const loadMore = useCallback(async () => { + if (!token || !state.cursor || state.loadingMore) { + return; + } + + setState((prev) => ({ ...prev, loadingMore: true })); + + try { + const response = await fetchGalleryPhotos(token, state.cursor, GALLERY_PAGE_SIZE); + setState((prev) => ({ + ...prev, + photos: [...prev.photos, ...response.data], + cursor: response.next_cursor, + loadingMore: false, + })); + } catch (error) { + const err = error as Error; + setState((prev) => ({ + ...prev, + loadingMore: false, + error: err.message || t('galleryPublic.loadError'), + })); + } + }, [state.cursor, state.loadingMore, token, t]); + + useEffect(() => { + if (!state.cursor || !sentinelRef.current) { + return; + } + + const observer = new IntersectionObserver((entries) => { + const firstEntry = entries[0]; + if (firstEntry?.isIntersecting) { + loadMore(); + } + }, { + rootMargin: '400px', + threshold: 0, + }); + + observer.observe(sentinelRef.current); + + return () => observer.disconnect(); + }, [state.cursor, loadMore]); + + const themeStyles = useMemo(() => { + if (!state.meta) { + return {} as React.CSSProperties; + } + + return { + '--gallery-primary': state.meta.branding.primary_color, + '--gallery-secondary': state.meta.branding.secondary_color, + '--gallery-background': state.meta.branding.background_color, + } as React.CSSProperties & Record; + }, [state.meta]); + + const headerStyle = useMemo(() => { + if (!state.meta) { + return {}; + } + return { + background: state.meta.branding.primary_color, + color: '#ffffff', + } satisfies React.CSSProperties; + }, [state.meta]); + + const accentStyle = useMemo(() => { + if (!state.meta) { + return {}; + } + return { + color: state.meta.branding.primary_color, + } satisfies React.CSSProperties; + }, [state.meta]); + + const backgroundStyle = useMemo(() => { + if (!state.meta) { + return {}; + } + return { + backgroundColor: state.meta.branding.background_color, + } satisfies React.CSSProperties; + }, [state.meta]); + + const openLightbox = useCallback((photo: GalleryPhotoResource) => { + setSelectedPhoto(photo); + setLightboxOpen(true); + }, []); + + const closeLightbox = useCallback(() => { + setLightboxOpen(false); + setSelectedPhoto(null); + }, []); + + if (!token) { + return null; + } + + if (state.expired) { + return ( +
+ +
+

{t('galleryPublic.expiredTitle')}

+

{t('galleryPublic.expiredDescription')}

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

Fotospiel

+

+ {state.meta?.event.name || t('galleryPublic.title')} +

+ {state.meta?.event.gallery_expires_at && ( +

+ {new Date(state.meta.event.gallery_expires_at).toLocaleDateString()} +

+ )} +
+
+
+ +
+ {state.meta?.event.description && ( +
+

{state.meta.event.description}

+
+ )} + + {state.error && ( + + {t('galleryPublic.loadError')} + + {state.error} + + + )} + + {state.loading && ( +
+ +

{t('galleryPublic.loading')}

+
+ )} + + {!state.loading && state.photos.length === 0 && !state.error && ( +
+

{t('galleryPublic.emptyTitle')}

+

{t('galleryPublic.emptyDescription')}

+
+ )} + +
+ {state.photos.map((photo) => ( + + ))} +
+ +
+ + {state.loadingMore && ( +
+ + {t('galleryPublic.loadingMore')} +
+ )} + + {!state.loading && state.cursor && ( +
+ +
+ )} +
+ + (open ? setLightboxOpen(true) : closeLightbox())}> + +
+
+

+ {selectedPhoto?.guest_name || t('galleryPublic.lightboxGuestFallback')} +

+

+ {selectedPhoto?.created_at ? new Date(selectedPhoto.created_at).toLocaleString() : ''} +

+
+ +
+ +
+ {selectedPhoto?.full_url && ( + {selectedPhoto?.guest_name + )} +
+ + +
+ {selectedPhoto?.likes_count ? `${selectedPhoto.likes_count} ❤` : ''} +
+ {selectedPhoto?.download_url && ( + + )} +
+
+
+
+ ); +} diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx index fdca6e3..f09fa27 100644 --- a/resources/js/guest/router.tsx +++ b/resources/js/guest/router.tsx @@ -21,6 +21,7 @@ import AchievementsPage from './pages/AchievementsPage'; import SlideshowPage from './pages/SlideshowPage'; import SettingsPage from './pages/SettingsPage'; import LegalPage from './pages/LegalPage'; +import PublicGalleryPage from './pages/PublicGalleryPage'; import NotFoundPage from './pages/NotFoundPage'; import { LocaleProvider } from './i18n/LocaleContext'; import { DEFAULT_LOCALE, isLocaleCode } from './i18n/messages'; @@ -57,6 +58,7 @@ export const router = createBrowserRouter([ { index: true, element: }, ], }, + { path: '/g/:token', element: }, { path: '/e/:token', element: , diff --git a/resources/js/guest/services/galleryApi.ts b/resources/js/guest/services/galleryApi.ts new file mode 100644 index 0000000..abb7d8c --- /dev/null +++ b/resources/js/guest/services/galleryApi.ts @@ -0,0 +1,81 @@ +import type { LocaleCode } from '../i18n/messages'; + +export interface GalleryBranding { + primary_color: string; + secondary_color: string; + background_color: string; +} + +export interface GalleryMetaResponse { + event: { + id: number; + name: string; + slug?: string | null; + description?: string | null; + gallery_expires_at?: string | null; + }; + branding: GalleryBranding; +} + +export interface GalleryPhotoResource { + id: number; + thumbnail_url: string | null; + full_url: string | null; + download_url: string; + likes_count: number; + guest_name?: string | null; + created_at?: string | null; +} + +export interface GalleryPhotosResponse { + data: GalleryPhotoResource[]; + next_cursor: string | null; +} + +async function handleResponse(response: Response): Promise { + if (response.status === 204) { + return {} as T; + } + + const data = await response.json().catch(() => null); + + if (!response.ok) { + const error = new Error((data && data.error && data.error.message) || 'Request failed'); + (error as any).code = data?.error?.code ?? response.status; + throw error; + } + + return data as T; +} + +export async function fetchGalleryMeta(token: string, locale?: LocaleCode): Promise { + const params = new URLSearchParams(); + if (locale) params.set('locale', locale); + + const response = await fetch(`/api/v1/gallery/${encodeURIComponent(token)}${params.toString() ? `?${params.toString()}` : ''}`, { + headers: { + 'Accept': 'application/json', + }, + credentials: 'omit', + }); + + return handleResponse(response); +} + +export async function fetchGalleryPhotos(token: string, cursor?: string | null, limit = 30): Promise { + const params = new URLSearchParams(); + params.set('limit', String(limit)); + if (cursor) { + params.set('cursor', cursor); + } + + const response = await fetch(`/api/v1/gallery/${encodeURIComponent(token)}/photos?${params.toString()}`, { + headers: { + 'Accept': 'application/json', + }, + credentials: 'omit', + }); + + return handleResponse(response); +} + diff --git a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx index de415bd..89ede2c 100644 --- a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx +++ b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useRef, useEffect } from "react"; import { useTranslation } from 'react-i18next'; import { Steps } from "@/components/ui/Steps"; import { Button } from "@/components/ui/button"; @@ -54,6 +54,8 @@ const baseStepConfig: { id: CheckoutStepId; titleKey: string; descriptionKey: st const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: string; privacyHtml: string }> = ({ stripePublishableKey, paypalClientId, privacyHtml }) => { const { t } = useTranslation('marketing'); const { currentStep, nextStep, previousStep } = useCheckoutWizard(); + const progressRef = useRef(null); + const hasMountedRef = useRef(false); const stepConfig = useMemo(() => baseStepConfig.map(step => ({ @@ -73,9 +75,29 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin return (currentIndex / (stepConfig.length - 1)) * 100; }, [currentIndex, stepConfig]); + useEffect(() => { + if (typeof window === 'undefined' || !progressRef.current) { + return; + } + + if (!hasMountedRef.current) { + hasMountedRef.current = true; + return; + } + + const element = progressRef.current; + const rect = element.getBoundingClientRect(); + const scrollTop = window.scrollY + rect.top - 16; // slightly above the progress bar + + window.scrollTo({ + top: Math.max(scrollTop, 0), + behavior: 'smooth', + }); + }, [currentStep]); + return (
-
+
= 0 ? currentIndex : 0} />
diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index b0eeafb..6c93a63 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -76,6 +76,7 @@ return [ 'actions' => [ 'toggle_active' => 'Aktiv umschalten', 'join_link_qr' => 'Beitrittslink / QR', + 'download_photos' => 'Alle Fotos herunterladen', ], 'modal' => [ 'join_link_heading' => 'Beitrittslink der Veranstaltung', diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 91c661d..c13502a 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -76,6 +76,7 @@ return [ 'actions' => [ 'toggle_active' => 'Toggle Active', 'join_link_qr' => 'Join Link / QR', + 'download_photos' => 'Download all photos', ], 'modal' => [ 'join_link_heading' => 'Event Join Link', diff --git a/routes/api.php b/routes/api.php index 7bb08ca..e4eba6a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -38,6 +38,12 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show'); Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like'); Route::post('/events/{token}/upload', [EventPublicController::class, 'upload'])->name('events.upload'); + + Route::get('/gallery/{token}', [EventPublicController::class, 'gallery'])->name('gallery.show'); + Route::get('/gallery/{token}/photos', [EventPublicController::class, 'galleryPhotos'])->name('gallery.photos'); + Route::get('/gallery/{token}/photos/{photo}/download', [EventPublicController::class, 'galleryPhotoDownload']) + ->whereNumber('photo') + ->name('gallery.photos.download'); }); Route::middleware(['tenant.token', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () { diff --git a/routes/web.php b/routes/web.php index ce77a1f..7f397f8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use App\Http\Controllers\CheckoutController; use App\Http\Controllers\LocaleController; use App\Http\Controllers\LegalPageController; use App\Http\Controllers\MarketingController; +use App\Http\Controllers\Tenant\EventPhotoArchiveController; use Illuminate\Support\Facades\Route; use Inertia\Inertia; @@ -39,10 +40,16 @@ Route::get('/anlaesse/{type}', [MarketingController::class, 'occasionsType'])->n Route::get('/success/{packageId?}', [MarketingController::class, 'success'])->name('marketing.success'); Route::view('/event-admin/{view?}', 'admin')->where('view', '.*')->name('tenant.admin.app'); Route::view('/event', 'guest')->name('guest.pwa.landing'); +Route::view('/g/{token}', 'guest')->where('token', '.*')->name('guest.gallery'); Route::middleware('auth')->group(function () { Route::get('/buy/{packageId}', [MarketingController::class, 'buyPackages'])->name('marketing.buy'); }); +Route::middleware('auth')->group(function () { + Route::get('/tenant/events/{event}/photos/archive', EventPhotoArchiveController::class) + ->name('tenant.events.photos.archive'); +}); + Route::get('/purchase-wizard/{package}', [CheckoutController::class, 'show'])->name('purchase.wizard'); Route::get('/checkout/{package}', [CheckoutController::class, 'show'])->name('checkout.show'); Route::post('/checkout/login', [CheckoutController::class, 'login'])->name('checkout.login');