diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php
index 885e26f8..f1928ab2 100644
--- a/app/Http/Controllers/Api/EventPublicController.php
+++ b/app/Http/Controllers/Api/EventPublicController.php
@@ -53,6 +53,10 @@ class EventPublicController extends BaseController
private const BRANDING_SIGNED_TTL_SECONDS = 3600;
+ private const PREVIEW_MAX_EDGE = 1920;
+
+ private const PREVIEW_QUALITY = 86;
+
private ?GuestPolicySetting $guestPolicy = null;
public function __construct(
@@ -1451,17 +1455,34 @@ class EventPublicController extends BaseController
}
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
+ {
+ return $this->makeSignedGalleryDownloadUrlForId($token, (int) $photo->id);
+ }
+
+ private function makeSignedGalleryDownloadUrlForId(string $token, int $photoId): string
{
return URL::temporarySignedRoute(
'api.v1.gallery.photos.download',
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
[
'token' => $token,
- 'photo' => $photo->id,
+ 'photo' => $photoId,
]
);
}
+ private function galleryDownloadVariantPreference(Event $event): array
+ {
+ $settings = is_array($event->settings) ? $event->settings : [];
+ $configuredVariant = Arr::get($settings, 'guest_download_variant', 'preview');
+
+ if ($configuredVariant === 'original') {
+ return ['original'];
+ }
+
+ return ['preview', 'original'];
+ }
+
private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string
{
return URL::temporarySignedRoute(
@@ -1907,7 +1928,12 @@ class EventPublicController extends BaseController
);
}
- return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
+ return $this->streamGalleryPhoto(
+ $event,
+ $record,
+ $this->galleryDownloadVariantPreference($event),
+ 'attachment'
+ );
}
public function event(Request $request, string $token)
@@ -2219,6 +2245,15 @@ class EventPublicController extends BaseController
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
$path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? 'image/jpeg';
+ } elseif ($variant === 'preview') {
+ $asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'preview')->first();
+ $watermarked = $preferOriginals
+ ? null
+ : EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked_preview')->first();
+ $fallbackAsset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
+ $disk = $watermarked?->disk ?? $asset?->disk ?? $fallbackAsset?->disk;
+ $path = $watermarked?->path ?? $asset?->path ?? $fallbackAsset?->path ?? ($record->file_path ?? null);
+ $mime = $watermarked?->mime_type ?? $asset?->mime_type ?? $fallbackAsset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
} else {
$watermarked = $preferOriginals
? null
@@ -2909,10 +2944,15 @@ class EventPublicController extends BaseController
$query->where('photos.created_at', '>', $since);
}
$rows = $query->get()->map(function ($r) use ($fallbacks, $token, $deviceId) {
- $r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
+ $fullUrl = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? ''));
- $r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
+ $thumbnailUrl = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
?? $this->resolveSignedFallbackUrl((string) ($r->thumbnail_path ?? ''));
+ $r->file_path = $fullUrl;
+ $r->thumbnail_path = $thumbnailUrl;
+ $r->full_url = $fullUrl;
+ $r->thumbnail_url = $thumbnailUrl;
+ $r->download_url = $this->makeSignedGalleryDownloadUrlForId($token, (int) $r->id);
// Localize task title if present
if ($r->task_title) {
@@ -3346,10 +3386,19 @@ class EventPublicController extends BaseController
$thumbUrl = $thumbPath
? $this->resolveDiskUrl($disk, $thumbPath)
: $this->resolveDiskUrl($disk, $path);
+ $previewRel = "events/{$eventId}/photos/previews/{$baseName}_preview.jpg";
+ $previewPath = ImageHelper::makeThumbnailOnDisk(
+ $disk,
+ $path,
+ $previewRel,
+ self::PREVIEW_MAX_EDGE,
+ self::PREVIEW_QUALITY
+ );
// Create watermarked copies (non-destructive).
$watermarkedPath = $path;
$watermarkedThumb = $thumbPath ?: $path;
+ $watermarkedPreview = $previewPath ?: $path;
if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) {
$watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventId}/photos/watermarked/{$baseName}.{$file->getClientOriginalExtension()}", $watermarkConfig) ?? $path;
if ($thumbPath) {
@@ -3362,6 +3411,17 @@ class EventPublicController extends BaseController
} else {
$watermarkedThumb = $watermarkedPath;
}
+
+ if ($previewPath) {
+ $watermarkedPreview = ImageHelper::copyWithWatermark(
+ $disk,
+ $previewPath,
+ "events/{$eventId}/photos/watermarked/{$baseName}_preview.jpg",
+ $watermarkConfig
+ ) ?? $previewPath;
+ } else {
+ $watermarkedPreview = $watermarkedPath;
+ }
}
$url = $this->resolveDiskUrl($disk, $watermarkedPath);
@@ -3475,6 +3535,23 @@ class EventPublicController extends BaseController
],
]);
}
+
+ if ($previewPath) {
+ $this->eventStorageManager->recordAsset($eventModel, $disk, $previewPath, [
+ 'variant' => 'preview',
+ 'mime_type' => 'image/jpeg',
+ 'status' => 'hot',
+ 'processed_at' => now(),
+ 'photo_id' => $photoId,
+ 'size_bytes' => Storage::disk($disk)->exists($previewPath)
+ ? Storage::disk($disk)->size($previewPath)
+ : null,
+ 'meta' => [
+ 'source_variant_id' => $asset->id,
+ ],
+ ]);
+ }
+
if ($watermarkedThumb !== $thumbPath) {
$this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [
'variant' => 'watermarked_thumbnail',
@@ -3491,6 +3568,22 @@ class EventPublicController extends BaseController
]);
}
+ if ($watermarkedPreview !== $previewPath) {
+ $this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedPreview, [
+ 'variant' => 'watermarked_preview',
+ 'mime_type' => 'image/jpeg',
+ 'status' => 'hot',
+ 'processed_at' => now(),
+ 'photo_id' => $photoId,
+ 'size_bytes' => Storage::disk($disk)->exists($watermarkedPreview)
+ ? Storage::disk($disk)->size($watermarkedPreview)
+ : null,
+ 'meta' => [
+ 'source_variant_id' => $watermarkedAsset?->id ?? $asset->id,
+ ],
+ ]);
+ }
+
DB::table('photos')
->where('id', $photoId)
->update(['media_asset_id' => $asset->id]);
diff --git a/app/Http/Requests/Tenant/EventStoreRequest.php b/app/Http/Requests/Tenant/EventStoreRequest.php
index 2196517f..9a13989f 100644
--- a/app/Http/Requests/Tenant/EventStoreRequest.php
+++ b/app/Http/Requests/Tenant/EventStoreRequest.php
@@ -55,6 +55,7 @@ class EventStoreRequest extends FormRequest
'settings.branding.*' => ['nullable'],
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
'settings.guest_downloads_enabled' => ['nullable', 'boolean'],
+ 'settings.guest_download_variant' => ['nullable', Rule::in(['preview', 'original'])],
'settings.guest_sharing_enabled' => ['nullable', 'boolean'],
'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])],
'settings.live_show' => ['nullable', 'array'],
diff --git a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx
index f3b2ca3f..0afdd50d 100644
--- a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx
+++ b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
const setSearchParamsMock = vi.fn();
const pushGuestToastMock = vi.fn();
@@ -199,4 +199,40 @@ describe('GalleryScreen', () => {
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument()
);
});
+
+ it('uses download_url when downloading from lightbox', async () => {
+ fetchGalleryMock.mockResolvedValue({
+ data: [{
+ id: 123,
+ thumbnail_url: '/storage/demo-thumb.jpg',
+ download_url: '/api/v1/gallery/demo/photos/123/download?signature=abc',
+ likes_count: 2,
+ }],
+ });
+ fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
+
+ const originalCreateElement = document.createElement.bind(document);
+ const clickSpy = vi.fn();
+ let createdLink: HTMLAnchorElement | null = null;
+
+ const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
+ const element = originalCreateElement(tagName) as HTMLElement;
+ if (tagName.toLowerCase() === 'a') {
+ createdLink = element as HTMLAnchorElement;
+ (createdLink as any).click = clickSpy;
+ }
+ return element;
+ });
+
+ render();
+
+ await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled());
+ const downloadButton = await screen.findByRole('button', { name: /download/i });
+ fireEvent.click(downloadButton);
+
+ expect(clickSpy).toHaveBeenCalled();
+ expect(createdLink?.getAttribute('href')).toBe('/api/v1/gallery/demo/photos/123/download?signature=abc');
+
+ createElementSpy.mockRestore();
+ });
});
diff --git a/resources/js/guest-v2/screens/GalleryScreen.tsx b/resources/js/guest-v2/screens/GalleryScreen.tsx
index 223bc94b..12cc0767 100644
--- a/resources/js/guest-v2/screens/GalleryScreen.tsx
+++ b/resources/js/guest-v2/screens/GalleryScreen.tsx
@@ -25,6 +25,8 @@ type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
type GalleryTile = {
id: number;
imageUrl: string;
+ fullUrl?: string | null;
+ downloadUrl?: string | null;
likes: number;
createdAt?: string | null;
ingestSource?: string | null;
@@ -42,6 +44,8 @@ type GalleryTile = {
type LightboxPhoto = {
id: number;
imageUrl: string;
+ fullUrl?: string | null;
+ downloadUrl?: string | null;
likes: number;
isMine?: boolean;
taskId?: number | null;
@@ -63,6 +67,9 @@ function normalizeImageUrl(src?: string | null) {
}
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
+ if (cleanPath.startsWith('api/')) {
+ return `/${cleanPath}`;
+ }
if (!cleanPath.startsWith('storage/')) {
cleanPath = `storage/${cleanPath}`;
}
@@ -175,14 +182,23 @@ export default function GalleryScreen() {
const record = photo as Record;
const id = Number(record.id ?? 0);
const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 0;
+ const fullUrl = normalizeImageUrl(
+ (record.full_url as string | null | undefined)
+ ?? (record.file_path as string | null | undefined)
+ ?? (record.url as string | null | undefined)
+ ?? (record.image_url as string | null | undefined)
+ );
const imageUrl = normalizeImageUrl(
(record.thumbnail_url as string | null | undefined)
?? (record.thumbnail_path as string | null | undefined)
- ?? (record.file_path as string | null | undefined)
- ?? (record.full_url as string | null | undefined)
+ ?? fullUrl
?? (record.url as string | null | undefined)
?? (record.image_url as string | null | undefined)
);
+ const downloadUrl = normalizeImageUrl(
+ (record.download_url as string | null | undefined)
+ ?? fullUrl
+ );
const rawTaskId = Number(record.task_id ?? record.taskId ?? 0);
const taskId = Number.isFinite(rawTaskId) && rawTaskId > 0 ? rawTaskId : null;
const taskLabel =
@@ -226,6 +242,8 @@ export default function GalleryScreen() {
return {
id,
imageUrl,
+ fullUrl: fullUrl || null,
+ downloadUrl: downloadUrl || null,
likes: likesCount,
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
@@ -379,14 +397,23 @@ export default function GalleryScreen() {
const record = photo as Record;
const id = Number(record.id ?? 0);
const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 0;
+ const fullUrl = normalizeImageUrl(
+ (record.full_url as string | null | undefined)
+ ?? (record.file_path as string | null | undefined)
+ ?? (record.url as string | null | undefined)
+ ?? (record.image_url as string | null | undefined)
+ );
const imageUrl = normalizeImageUrl(
(record.thumbnail_url as string | null | undefined)
?? (record.thumbnail_path as string | null | undefined)
- ?? (record.file_path as string | null | undefined)
- ?? (record.full_url as string | null | undefined)
+ ?? fullUrl
?? (record.url as string | null | undefined)
?? (record.image_url as string | null | undefined)
);
+ const downloadUrl = normalizeImageUrl(
+ (record.download_url as string | null | undefined)
+ ?? fullUrl
+ );
if (!id || !imageUrl || existing.has(id)) {
return null;
}
@@ -395,6 +422,8 @@ export default function GalleryScreen() {
return {
id,
imageUrl,
+ fullUrl: fullUrl || null,
+ downloadUrl: downloadUrl || null,
likes: likesCount,
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
@@ -447,6 +476,8 @@ export default function GalleryScreen() {
? {
id: lightboxSelected.id,
imageUrl: lightboxSelected.imageUrl,
+ fullUrl: lightboxSelected.fullUrl ?? null,
+ downloadUrl: lightboxSelected.downloadUrl ?? null,
likes: lightboxSelected.likes,
isMine: lightboxSelected.isMine,
taskId: lightboxSelected.taskId ?? null,
@@ -764,9 +795,10 @@ export default function GalleryScreen() {
);
const downloadPhoto = React.useCallback((photo?: LightboxPhoto | null) => {
- if (!photo?.imageUrl) return;
+ const url = photo?.downloadUrl ?? photo?.fullUrl ?? photo?.imageUrl ?? null;
+ if (!url) return;
const link = document.createElement('a');
- link.href = photo.imageUrl;
+ link.href = url;
link.download = `photo-${photo.id}.jpg`;
link.rel = 'noreferrer';
document.body.appendChild(link);
@@ -1498,14 +1530,23 @@ export default function GalleryScreen() {
function mapFullPhoto(photo: Record): LightboxPhoto | null {
const id = Number(photo.id ?? 0);
if (!id) return null;
- const imageUrl = normalizeImageUrl(
+ const fullUrl = normalizeImageUrl(
(photo.full_url as string | null | undefined)
?? (photo.file_path as string | null | undefined)
+ ?? (photo.url as string | null | undefined)
+ ?? (photo.image_url as string | null | undefined)
+ );
+ const imageUrl = normalizeImageUrl(
+ fullUrl
?? (photo.thumbnail_url as string | null | undefined)
?? (photo.thumbnail_path as string | null | undefined)
?? (photo.url as string | null | undefined)
?? (photo.image_url as string | null | undefined)
);
+ const downloadUrl = normalizeImageUrl(
+ (photo.download_url as string | null | undefined)
+ ?? fullUrl
+ );
if (!imageUrl) return null;
const taskLabel =
typeof photo.task_title === 'string'
@@ -1550,6 +1591,8 @@ function mapFullPhoto(photo: Record): LightboxPhoto | null {
return {
id,
imageUrl,
+ fullUrl: fullUrl || null,
+ downloadUrl: downloadUrl || null,
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
isMine,
taskId,
diff --git a/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx b/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx
index fa08b697..7d824ec9 100644
--- a/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx
+++ b/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx
@@ -22,6 +22,8 @@ import { GUEST_AI_MAGIC_EDITS_ENABLED } from '../lib/featureFlags';
type LightboxPhoto = {
id: number;
imageUrl: string;
+ fullUrl?: string | null;
+ downloadUrl?: string | null;
likes: number;
createdAt?: string | null;
ingestSource?: string | null;
@@ -37,6 +39,9 @@ function normalizeImageUrl(src?: string | null) {
}
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
+ if (cleanPath.startsWith('api/')) {
+ return `/${cleanPath}`;
+ }
if (!cleanPath.startsWith('storage/')) {
cleanPath = `storage/${cleanPath}`;
}
@@ -47,18 +52,29 @@ function normalizeImageUrl(src?: string | null) {
function mapPhoto(photo: Record): LightboxPhoto | null {
const id = Number(photo.id ?? 0);
if (!id) return null;
- const imageUrl = normalizeImageUrl(
+ const fullUrl = normalizeImageUrl(
(photo.full_url as string | null | undefined)
?? (photo.file_path as string | null | undefined)
+ ?? (photo.url as string | null | undefined)
+ ?? (photo.image_url as string | null | undefined)
+ );
+ const imageUrl = normalizeImageUrl(
+ fullUrl
?? (photo.thumbnail_url as string | null | undefined)
?? (photo.thumbnail_path as string | null | undefined)
?? (photo.url as string | null | undefined)
?? (photo.image_url as string | null | undefined)
);
+ const downloadUrl = normalizeImageUrl(
+ (photo.download_url as string | null | undefined)
+ ?? fullUrl
+ );
if (!imageUrl) return null;
return {
id,
imageUrl,
+ fullUrl: fullUrl || null,
+ downloadUrl: downloadUrl || null,
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
createdAt: typeof photo.created_at === 'string' ? photo.created_at : null,
ingestSource: typeof photo.ingest_source === 'string' ? photo.ingest_source : null,
@@ -617,7 +633,7 @@ export default function PhotoLightboxScreen() {
>