- 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:
Codex Agent
2025-10-17 23:24:06 +02:00
parent 5817270c35
commit ae9b9160ac
20 changed files with 1410 additions and 3 deletions

View File

@@ -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, [