- 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).
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -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
|
||||
|
||||
98
Dockerfile
Normal file
98
Dockerfile
Normal file
@@ -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"]
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, [
|
||||
|
||||
132
app/Http/Controllers/Tenant/EventPhotoArchiveController.php
Normal file
132
app/Http/Controllers/Tenant/EventPhotoArchiveController.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMediaAsset;
|
||||
use App\Models\Photo;
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use ZipArchive;
|
||||
|
||||
class EventPhotoArchiveController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Event $event): StreamedResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user || (int) $user->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;
|
||||
}
|
||||
}
|
||||
|
||||
137
docker-compose.yml
Normal file
137
docker-compose.yml
Normal file
@@ -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:
|
||||
109
docs/deployment/docker.md
Normal file
109
docs/deployment/docker.md
Normal file
@@ -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.
|
||||
|
||||
103
docs/queue-supervisor/README.md
Normal file
103
docs/queue-supervisor/README.md
Normal file
@@ -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.
|
||||
11
docs/queue-supervisor/horizon.sh
Normal file
11
docs/queue-supervisor/horizon.sh
Normal file
@@ -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
|
||||
|
||||
27
docs/queue-supervisor/queue-worker.sh
Normal file
27
docs/queue-supervisor/queue-worker.sh
Normal file
@@ -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[@]}"
|
||||
|
||||
@@ -178,6 +178,19 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
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<LocaleCode, NestedMessages> = {
|
||||
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.',
|
||||
|
||||
333
resources/js/guest/pages/PublicGalleryPage.tsx
Normal file
333
resources/js/guest/pages/PublicGalleryPage.tsx
Normal file
@@ -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<GalleryState>(INITIAL_STATE);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<GalleryPhotoResource | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(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<string, string>;
|
||||
}, [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 (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center" style={backgroundStyle}>
|
||||
<AlertTriangle className="h-12 w-12 text-destructive" aria-hidden />
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('galleryPublic.expiredTitle')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('galleryPublic.expiredDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ ...themeStyles, ...backgroundStyle }}>
|
||||
<header className="sticky top-0 z-20 shadow-sm" style={headerStyle}>
|
||||
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-5 py-4">
|
||||
<div className="text-left">
|
||||
<p className="text-xs uppercase tracking-widest opacity-80">Fotospiel</p>
|
||||
<h1 className="text-xl font-semibold leading-tight">
|
||||
{state.meta?.event.name || t('galleryPublic.title')}
|
||||
</h1>
|
||||
{state.meta?.event.gallery_expires_at && (
|
||||
<p className="text-[11px] opacity-80">
|
||||
{new Date(state.meta.event.gallery_expires_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-5 py-6">
|
||||
{state.meta?.event.description && (
|
||||
<div className="rounded-xl bg-white/70 p-4 shadow-sm backdrop-blur">
|
||||
<p className="text-sm text-muted-foreground">{state.meta.event.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('galleryPublic.loadError')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{state.error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{state.loading && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
|
||||
<p className="text-sm text-muted-foreground">{t('galleryPublic.loading')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!state.loading && state.photos.length === 0 && !state.error && (
|
||||
<div className="rounded-xl border border-dashed border-muted/60 p-10 text-center">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('galleryPublic.emptyTitle')}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('galleryPublic.emptyDescription')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{state.photos.map((photo) => (
|
||||
<button
|
||||
key={photo.id}
|
||||
type="button"
|
||||
className="group relative overflow-hidden rounded-xl bg-white shadow-sm transition-transform hover:-translate-y-1 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||
onClick={() => openLightbox(photo)}
|
||||
style={accentStyle}
|
||||
>
|
||||
<img
|
||||
src={photo.thumbnail_url ?? photo.full_url ?? ''}
|
||||
alt={photo.guest_name ? `${photo.guest_name}s Foto` : `Foto ${photo.id}`}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div ref={sentinelRef} className="h-1 w-full" aria-hidden />
|
||||
|
||||
{state.loadingMore && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
{t('galleryPublic.loadingMore')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!state.loading && state.cursor && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={loadMore} disabled={state.loadingMore}>
|
||||
{state.loadingMore ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||
{t('galleryPublic.loadMore')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Dialog open={lightboxOpen} onOpenChange={(open) => (open ? setLightboxOpen(true) : closeLightbox())}>
|
||||
<DialogContent className="max-w-3xl gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{selectedPhoto?.guest_name || t('galleryPublic.lightboxGuestFallback')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedPhoto?.created_at ? new Date(selectedPhoto.created_at).toLocaleString() : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={closeLightbox}>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
<span className="sr-only">{t('common.actions.close')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-2xl bg-black/5">
|
||||
{selectedPhoto?.full_url && (
|
||||
<img
|
||||
src={selectedPhoto.full_url}
|
||||
alt={selectedPhoto?.guest_name || `Foto ${selectedPhoto?.id}`}
|
||||
className="w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedPhoto?.likes_count ? `${selectedPhoto.likes_count} ❤` : ''}
|
||||
</div>
|
||||
{selectedPhoto?.download_url && (
|
||||
<Button asChild className="gap-2" style={accentStyle}>
|
||||
<a href={selectedPhoto.download_url} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="h-4 w-4" aria-hidden />
|
||||
{t('galleryPublic.download')}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: <ProfileSetupPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '/g/:token', element: <PublicGalleryPage /> },
|
||||
{
|
||||
path: '/e/:token',
|
||||
element: <HomeLayout />,
|
||||
|
||||
81
resources/js/guest/services/galleryApi.ts
Normal file
81
resources/js/guest/services/galleryApi.ts
Normal file
@@ -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<T>(response: Response): Promise<T> {
|
||||
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<GalleryMetaResponse> {
|
||||
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<GalleryMetaResponse>(response);
|
||||
}
|
||||
|
||||
export async function fetchGalleryPhotos(token: string, cursor?: string | null, limit = 30): Promise<GalleryPhotosResponse> {
|
||||
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<GalleryPhotosResponse>(response);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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 (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div ref={progressRef} className="space-y-4">
|
||||
<Progress value={progress} />
|
||||
<Steps steps={stepConfig} currentStep={currentIndex >= 0 ? currentIndex : 0} />
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user