Add mobile save sheet with same-origin photo downloads.

Phones cannot land downloads in the photo library, so the gallery opens a share sheet for a cacheable preview while streaming via a dedicated download endpoint and recording preview sizes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-08-06 20:14:31 +00:00
co-authored by Cursor
parent 390e1dc0ea
commit be4dd5b47f
20 changed files with 962 additions and 56 deletions
@@ -15,6 +15,7 @@ import {
MasonryGrid,
} from '@/components/gallery/GalleryLayout';
import { useDownloads } from '@/components/gallery/useDownloads';
import SaveSheet, { SavePhoto, useMobileSave } from '@/components/gallery/SaveSheet';
import Lightbox from '@/components/Lightbox';
import LoginModal from '@/components/LoginModal';
import {
@@ -53,12 +54,30 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
download: es ? 'Descargar' : 'Download',
downloading: es ? 'Descargando…' : 'Downloading…',
};
const downloadPhoto = (photo: Photo) =>
// On a phone a download cannot reach the photo library, so the button
// opens a sheet offering the shareable preview instead (SaveSheet).
// Desktop keeps downloading the original straight away.
const mobileSave = useMobileSave();
const [savePhoto, setSavePhoto] = useState<SavePhoto | null>(null);
const downloadPhoto = (photo: Photo) => {
if (mobileSave) {
setSavePhoto({
id: photo.id,
previewUrl: photo.urls.download || photo.urls.preview || photo.urls.original,
originalUrl: photo.urls.downloadOriginal || photo.urls.original,
previewSize: photo.previewSizeBytes,
originalSize: photo.sizeBytes,
});
return;
}
downloads.start({
id: photo.id,
url: photo.urls.original,
filename: photo.originalFilename,
});
};
// Server-rendered public galleries need no client fetch. Everything else
// (link/ticket/private) is fetched here with the share token and/or the
@@ -235,10 +254,12 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
}
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
// previewUrl is the download endpoint's preview: rendering and saving from
// the same URL is what lets "Save photo" be answered by the HTTP cache.
const lightboxItems = readyPhotos.map((p) => ({
id: p.id,
previewUrl: p.urls.preview || p.urls.original,
downloadUrl: p.urls.original,
previewUrl: p.urls.download || p.urls.preview || p.urls.original,
downloadUrl: p.urls.downloadOriginal || p.urls.original,
filename: p.originalFilename,
thumbUrl: p.urls.thumb,
}));
@@ -263,7 +284,11 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
// first photo. Falls back to a navy gradient with no image.
const coverPhoto =
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null;
// Same preview URL the lightbox uses, so the cover photo's bytes are
// fetched once for both.
const heroUrl = coverPhoto
? coverPhoto.urls.download || coverPhoto.urls.preview || coverPhoto.urls.thumb
: null;
return (
<div>
@@ -338,15 +363,20 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
index={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex}
onDownload={(it) =>
downloads.start({ id: it.id, url: it.downloadUrl, filename: it.filename })
}
onDownload={(it) => {
const photo = readyPhotos.find((p) => p.id === it.id);
if (photo) downloadPhoto(photo);
}}
downloadingId={
lightboxItems.find((it) => downloads.isPending(it.id))?.id ?? null
}
downloadLabels={downloadLabels}
/>
)}
{/* Shared by both entry points: the tile button and the lightbox's.
Fetches nothing until it is open. */}
<SaveSheet photo={savePhoto} onClose={() => setSavePhoto(null)} />
</GalleryContainer>
{/* Call to action: send attendees to their dashboard, everyone else to