- 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

@@ -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;
}
}