- 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'))
|
->label(__('admin.events.actions.toggle_active'))
|
||||||
->icon('heroicon-o-power')
|
->icon('heroicon-o-power')
|
||||||
->action(fn ($record) => $record->update(['is_active' => ! $record->is_active])),
|
->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')
|
Actions\Action::make('join_tokens')
|
||||||
->label(__('admin.events.actions.join_link_qr'))
|
->label(__('admin.events.actions.join_link_qr'))
|
||||||
->icon('heroicon-o-qr-code')
|
->icon('heroicon-o-qr-code')
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Filament\Actions\BulkActionGroup;
|
|||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Actions\ViewAction;
|
use Filament\Actions\ViewAction;
|
||||||
use Filament\Forms\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
@@ -17,6 +18,9 @@ use App\Services\Storage\EventStorageManager;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventJoinToken;
|
use App\Models\EventJoinToken;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Models\EventMediaAsset;
|
||||||
|
|
||||||
class EventPublicController extends BaseController
|
class EventPublicController extends BaseController
|
||||||
{
|
{
|
||||||
@@ -103,6 +107,45 @@ class EventPublicController extends BaseController
|
|||||||
return [$event, $joinToken];
|
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
|
private function handleTokenFailure(Request $request, string $rateLimiterKey, string $code, int $status, array $context = []): JsonResponse
|
||||||
{
|
{
|
||||||
if (RateLimiter::tooManyAttempts($rateLimiterKey, 10)) {
|
if (RateLimiter::tooManyAttempts($rateLimiterKey, 10)) {
|
||||||
@@ -175,6 +218,251 @@ class EventPublicController extends BaseController
|
|||||||
|
|
||||||
return $path; // fallback as-is
|
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)
|
public function event(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$result = $this->resolvePublishedEvent($request, $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',
|
title: 'Nicht gefunden',
|
||||||
description: 'Die Seite konnte nicht gefunden werden.',
|
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: {
|
uploadQueue: {
|
||||||
title: 'Uploads',
|
title: 'Uploads',
|
||||||
description: 'Warteschlange mit Fortschritt und erneuten Versuchen; Hintergrund-Sync umschalten.',
|
description: 'Warteschlange mit Fortschritt und erneuten Versuchen; Hintergrund-Sync umschalten.',
|
||||||
@@ -510,6 +523,19 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
title: 'Not found',
|
title: 'Not found',
|
||||||
description: 'We could not find the page you requested.',
|
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: {
|
uploadQueue: {
|
||||||
title: 'Uploads',
|
title: 'Uploads',
|
||||||
description: 'Queue with progress/retry and background sync toggle.',
|
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 SlideshowPage from './pages/SlideshowPage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
import LegalPage from './pages/LegalPage';
|
import LegalPage from './pages/LegalPage';
|
||||||
|
import PublicGalleryPage from './pages/PublicGalleryPage';
|
||||||
import NotFoundPage from './pages/NotFoundPage';
|
import NotFoundPage from './pages/NotFoundPage';
|
||||||
import { LocaleProvider } from './i18n/LocaleContext';
|
import { LocaleProvider } from './i18n/LocaleContext';
|
||||||
import { DEFAULT_LOCALE, isLocaleCode } from './i18n/messages';
|
import { DEFAULT_LOCALE, isLocaleCode } from './i18n/messages';
|
||||||
@@ -57,6 +58,7 @@ export const router = createBrowserRouter([
|
|||||||
{ index: true, element: <ProfileSetupPage /> },
|
{ index: true, element: <ProfileSetupPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ path: '/g/:token', element: <PublicGalleryPage /> },
|
||||||
{
|
{
|
||||||
path: '/e/:token',
|
path: '/e/:token',
|
||||||
element: <HomeLayout />,
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Steps } from "@/components/ui/Steps";
|
import { Steps } from "@/components/ui/Steps";
|
||||||
import { Button } from "@/components/ui/button";
|
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 WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: string; privacyHtml: string }> = ({ stripePublishableKey, paypalClientId, privacyHtml }) => {
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const { currentStep, nextStep, previousStep } = useCheckoutWizard();
|
const { currentStep, nextStep, previousStep } = useCheckoutWizard();
|
||||||
|
const progressRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const hasMountedRef = useRef(false);
|
||||||
|
|
||||||
const stepConfig = useMemo(() =>
|
const stepConfig = useMemo(() =>
|
||||||
baseStepConfig.map(step => ({
|
baseStepConfig.map(step => ({
|
||||||
@@ -73,9 +75,29 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
|
|||||||
return (currentIndex / (stepConfig.length - 1)) * 100;
|
return (currentIndex / (stepConfig.length - 1)) * 100;
|
||||||
}, [currentIndex, stepConfig]);
|
}, [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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-4">
|
<div ref={progressRef} className="space-y-4">
|
||||||
<Progress value={progress} />
|
<Progress value={progress} />
|
||||||
<Steps steps={stepConfig} currentStep={currentIndex >= 0 ? currentIndex : 0} />
|
<Steps steps={stepConfig} currentStep={currentIndex >= 0 ? currentIndex : 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ return [
|
|||||||
'actions' => [
|
'actions' => [
|
||||||
'toggle_active' => 'Aktiv umschalten',
|
'toggle_active' => 'Aktiv umschalten',
|
||||||
'join_link_qr' => 'Beitrittslink / QR',
|
'join_link_qr' => 'Beitrittslink / QR',
|
||||||
|
'download_photos' => 'Alle Fotos herunterladen',
|
||||||
],
|
],
|
||||||
'modal' => [
|
'modal' => [
|
||||||
'join_link_heading' => 'Beitrittslink der Veranstaltung',
|
'join_link_heading' => 'Beitrittslink der Veranstaltung',
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ return [
|
|||||||
'actions' => [
|
'actions' => [
|
||||||
'toggle_active' => 'Toggle Active',
|
'toggle_active' => 'Toggle Active',
|
||||||
'join_link_qr' => 'Join Link / QR',
|
'join_link_qr' => 'Join Link / QR',
|
||||||
|
'download_photos' => 'Download all photos',
|
||||||
],
|
],
|
||||||
'modal' => [
|
'modal' => [
|
||||||
'join_link_heading' => 'Event Join Link',
|
'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::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show');
|
||||||
Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like');
|
Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like');
|
||||||
Route::post('/events/{token}/upload', [EventPublicController::class, 'upload'])->name('events.upload');
|
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 () {
|
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\LocaleController;
|
||||||
use App\Http\Controllers\LegalPageController;
|
use App\Http\Controllers\LegalPageController;
|
||||||
use App\Http\Controllers\MarketingController;
|
use App\Http\Controllers\MarketingController;
|
||||||
|
use App\Http\Controllers\Tenant\EventPhotoArchiveController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
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::get('/success/{packageId?}', [MarketingController::class, 'success'])->name('marketing.success');
|
||||||
Route::view('/event-admin/{view?}', 'admin')->where('view', '.*')->name('tenant.admin.app');
|
Route::view('/event-admin/{view?}', 'admin')->where('view', '.*')->name('tenant.admin.app');
|
||||||
Route::view('/event', 'guest')->name('guest.pwa.landing');
|
Route::view('/event', 'guest')->name('guest.pwa.landing');
|
||||||
|
Route::view('/g/{token}', 'guest')->where('token', '.*')->name('guest.gallery');
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
Route::get('/buy/{packageId}', [MarketingController::class, 'buyPackages'])->name('marketing.buy');
|
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('/purchase-wizard/{package}', [CheckoutController::class, 'show'])->name('purchase.wizard');
|
||||||
Route::get('/checkout/{package}', [CheckoutController::class, 'show'])->name('checkout.show');
|
Route::get('/checkout/{package}', [CheckoutController::class, 'show'])->name('checkout.show');
|
||||||
Route::post('/checkout/login', [CheckoutController::class, 'login'])->name('checkout.login');
|
Route::post('/checkout/login', [CheckoutController::class, 'login'])->name('checkout.login');
|
||||||
|
|||||||
Reference in New Issue
Block a user