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:
@@ -179,10 +179,14 @@ export default function Lightbox({
|
||||
)}
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{/* Nothing is layered over the image (the navigation buttons sit at
|
||||
the edges), and neither -webkit-touch-callout nor -webkit-user-
|
||||
select is suppressed here, so iOS long-press → "Save to Photos"
|
||||
still works as a backup to the save sheet. Don't add select-none. */}
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt=""
|
||||
className="max-w-[95vw] max-h-full object-contain select-none"
|
||||
className="max-w-[95vw] max-h-full object-contain [-webkit-touch-callout:default]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
@@ -87,13 +87,15 @@ export default function PhotoTile({
|
||||
aria-busy={downloading}
|
||||
aria-label={downloading ? labels.downloading : labels.download}
|
||||
className={clsx(
|
||||
'absolute bottom-2 right-2 hidden md:flex p-2 rounded-full bg-black/50 text-white transition-opacity hover:bg-black/80 focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary-yellow',
|
||||
'absolute bottom-2 right-2 flex p-2.5 md:p-2 rounded-full bg-black/50 text-white transition-opacity hover:bg-black/80 focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary-yellow',
|
||||
// Touch devices have no hover to reveal it, and this is where the
|
||||
// button matters most — it is the entry point to the save sheet.
|
||||
downloading
|
||||
? 'opacity-100 cursor-wait hover:bg-black/50'
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
: 'opacity-100 md:opacity-0 md:group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
{downloading ? <Spinner /> : <ArrowDownTrayIcon className="w-4 h-4" />}
|
||||
{downloading ? <Spinner /> : <ArrowDownTrayIcon className="w-5 h-5 md:w-4 md:h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowDownTrayIcon, PhotoIcon } from '@heroicons/react/24/outline';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import Spinner from '@/components/ui/Spinner';
|
||||
|
||||
// The mobile save flow. On a phone `<a download>` is a dead end for someone
|
||||
// who wants the photo in their camera roll: iOS is WebKit everywhere, so it
|
||||
// files the download under Files > Downloads and nothing else, and Android
|
||||
// drops it in Downloads where Google Photos may or may not pick it up. The
|
||||
// only route into the device gallery on either platform is the Web Share
|
||||
// API, which hands the file to the native share sheet ("Save Image" on iOS,
|
||||
// "Photos" on Android).
|
||||
//
|
||||
// So on mobile the download button opens this sheet instead of downloading,
|
||||
// and the default row shares the 2048px preview — a file a phone screen
|
||||
// cannot tell from the original, at a fraction of the bytes. The original
|
||||
// stays one tap away for anyone who actually wants it.
|
||||
|
||||
export interface SavePhoto {
|
||||
id: string;
|
||||
/** Preview download URL — the same URL the lightbox renders, so this is
|
||||
* usually answered from the HTTP cache rather than the network. */
|
||||
previewUrl: string;
|
||||
originalUrl: string;
|
||||
/** Byte sizes from the gallery response, used to label the rows. */
|
||||
previewSize?: number;
|
||||
originalSize?: number;
|
||||
}
|
||||
|
||||
type RowKey = 'preview' | 'original';
|
||||
type RowStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
interface RowState {
|
||||
status: RowStatus;
|
||||
/** 0..1, only tracked for the original's determinate progress bar. */
|
||||
progress: number;
|
||||
}
|
||||
|
||||
const IDLE: RowState = { status: 'idle', progress: 0 };
|
||||
|
||||
/**
|
||||
* True on finger-first devices, i.e. where a plain download lands in
|
||||
* Downloads instead of the photo library and this sheet is worth showing.
|
||||
* Feature detection only — user agent strings lie, and iPad has been
|
||||
* claiming to be a Mac for years.
|
||||
*
|
||||
* Deliberately NOT gated on `navigator.share`. Web Share only exists in a
|
||||
* secure context, so requiring it here made the sheet disappear entirely on
|
||||
* any plain-HTTP origin (a phone hitting the dev server over the LAN sees
|
||||
* `navigator.share === undefined`) and on Firefox for Android, which has no
|
||||
* file sharing. Both of those should still get the sheet — their rows just
|
||||
* fall back to a download. Whether a given row shares or downloads is
|
||||
* decided per file, at tap time, by canShareFile() below.
|
||||
*/
|
||||
export function useMobileSave(): boolean {
|
||||
const [mobile, setMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Comma = OR. `hover: none` catches touch devices that report a fine
|
||||
// pointer because a stylus is paired.
|
||||
const mq = window.matchMedia('(pointer: coarse), (hover: none)');
|
||||
const sync = () => setMobile(mq.matches);
|
||||
sync();
|
||||
mq.addEventListener('change', sync);
|
||||
return () => mq.removeEventListener('change', sync);
|
||||
}, []);
|
||||
|
||||
return mobile;
|
||||
}
|
||||
|
||||
function canShareFile(file: File): boolean {
|
||||
return typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] });
|
||||
}
|
||||
|
||||
/** Reads the server's Content-Disposition name (spanglish-<event>-<n>.jpg). */
|
||||
function filenameFrom(header: string | null, fallback: string): string {
|
||||
const match = header?.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i);
|
||||
return match ? decodeURIComponent(match[1]) : fallback;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | undefined, locale: string): string | null {
|
||||
if (!bytes || bytes <= 0) return null;
|
||||
const mb = bytes / 1_000_000;
|
||||
if (mb < 1) {
|
||||
return `${Math.round(bytes / 1000).toLocaleString(locale)} kB`;
|
||||
}
|
||||
return `${mb.toLocaleString(locale, { maximumFractionDigits: 1 })} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a URL into a File. onProgress is only wired up when the caller
|
||||
* wants a determinate bar — reading the body in chunks costs an extra copy,
|
||||
* which is not worth it for the preview that is normally already cached.
|
||||
*/
|
||||
async function fetchAsFile(
|
||||
url: string,
|
||||
signal: AbortSignal,
|
||||
onProgress?: (fraction: number) => void
|
||||
): Promise<File> {
|
||||
// No `cache` option: the default is what lets the lightbox's already
|
||||
// rendered preview be reused instead of refetched.
|
||||
const res = await fetch(url, { credentials: 'same-origin', signal });
|
||||
if (!res.ok) throw new Error(`Download failed (${res.status})`);
|
||||
|
||||
const type = res.headers.get('Content-Type') || 'image/jpeg';
|
||||
const name = filenameFrom(res.headers.get('Content-Disposition'), 'spanglish-photo.jpg');
|
||||
const total = Number(res.headers.get('Content-Length') || 0);
|
||||
|
||||
let blob: Blob;
|
||||
if (onProgress && total > 0 && res.body) {
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
onProgress(Math.min(1, received / total));
|
||||
}
|
||||
blob = new Blob(chunks as BlobPart[], { type });
|
||||
} else {
|
||||
blob = await res.blob();
|
||||
}
|
||||
return new File([blob], name, { type: blob.type || type });
|
||||
}
|
||||
|
||||
/** Fallback for browsers without file sharing: a plain download. */
|
||||
function saveViaAnchor(file: File) {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const a = document.createElement('a');
|
||||
a.href = objectUrl;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
|
||||
}
|
||||
|
||||
interface SaveSheetProps {
|
||||
/** The photo to save; null keeps the sheet closed. */
|
||||
photo: SavePhoto | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SaveSheet({ photo, onClose }: SaveSheetProps) {
|
||||
if (!photo) return null;
|
||||
// Keyed by photo so a sheet opened for a different photo starts clean
|
||||
// instead of inheriting the previous one's fetched file.
|
||||
return <SaveSheetPanel key={photo.id} photo={photo} onClose={onClose} />;
|
||||
}
|
||||
|
||||
function SaveSheetPanel({ photo, onClose }: { photo: SavePhoto; onClose: () => void }) {
|
||||
const { t, locale } = useLanguage();
|
||||
const [rows, setRows] = useState<Record<RowKey, RowState>>({
|
||||
preview: IDLE,
|
||||
original: IDLE,
|
||||
});
|
||||
|
||||
// Resolved files live in a ref as well as state: the tap handler has to
|
||||
// read them *synchronously*. iOS rejects navigator.share() with
|
||||
// NotAllowedError if anything is awaited between the tap and the call,
|
||||
// so there is no chance to read them out of a promise first.
|
||||
const files = useRef<Partial<Record<RowKey, File>>>({});
|
||||
const aborter = useRef<AbortController>(new AbortController());
|
||||
const closed = useRef(false);
|
||||
|
||||
const setRow = useCallback((key: RowKey, patch: Partial<RowState>) => {
|
||||
if (closed.current) return;
|
||||
setRows((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }));
|
||||
}, []);
|
||||
|
||||
const prepare = useCallback(
|
||||
async (key: RowKey): Promise<File | null> => {
|
||||
setRow(key, { status: 'loading', progress: 0 });
|
||||
try {
|
||||
const file = await fetchAsFile(
|
||||
key === 'preview' ? photo.previewUrl : photo.originalUrl,
|
||||
aborter.current.signal,
|
||||
// Only the original gets a determinate bar; the preview is
|
||||
// normally cached and resolves before a spinner would even paint.
|
||||
key === 'original' ? (progress) => setRow(key, { progress }) : undefined
|
||||
);
|
||||
files.current[key] = file;
|
||||
setRow(key, { status: 'ready', progress: 1 });
|
||||
return file;
|
||||
} catch (err) {
|
||||
// Aborting is how the sheet closes and how the user dismisses the
|
||||
// native share sheet — neither is an error worth reporting.
|
||||
if ((err as Error)?.name === 'AbortError') return null;
|
||||
setRow(key, { status: 'error', progress: 0 });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[photo.previewUrl, photo.originalUrl, setRow]
|
||||
);
|
||||
|
||||
// Nothing is fetched until the sheet is open — not on lightbox open, not
|
||||
// on swipe, not on scroll. Browsing the whole gallery without tapping
|
||||
// save costs exactly the thumbs and previews already on screen.
|
||||
useEffect(() => {
|
||||
// The controller is created here, not at render: closing the sheet
|
||||
// aborts it, so a re-run of this effect (React StrictMode double-invokes
|
||||
// it in development) has to start from a fresh, unaborted one.
|
||||
closed.current = false;
|
||||
const controller = new AbortController();
|
||||
aborter.current = controller;
|
||||
void prepare('preview');
|
||||
return () => {
|
||||
closed.current = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [prepare]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const deliver = (file: File) => {
|
||||
if (canShareFile(file)) {
|
||||
// Called with nothing awaited in front of it — see the ref above.
|
||||
navigator
|
||||
.share({ files: [file] })
|
||||
.then(() => onClose())
|
||||
.catch((err: Error) => {
|
||||
// AbortError just means the share sheet was dismissed.
|
||||
if (err?.name !== 'AbortError') saveViaAnchor(file);
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveViaAnchor(file);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onRowTap = (key: RowKey) => {
|
||||
const ready = files.current[key];
|
||||
if (ready) {
|
||||
deliver(ready);
|
||||
return;
|
||||
}
|
||||
if (rows[key].status === 'loading') return;
|
||||
// Not fetched yet (or it failed): fetch now and stop. The row goes back
|
||||
// to tappable when it resolves, so the share happens on a fresh
|
||||
// gesture rather than a stale one iOS would refuse.
|
||||
void prepare(key);
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
preview: formatBytes(photo.previewSize, locale),
|
||||
original: formatBytes(photo.originalSize, locale),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex flex-col justify-end"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('gallery.save.sheetTitle')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('gallery.save.cancel')}
|
||||
className="absolute inset-0 bg-black/60 motion-safe:animate-sheet-fade"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full sm:max-w-md sm:mx-auto bg-white rounded-t-2xl sm:rounded-2xl sm:mb-4 shadow-xl p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] motion-safe:animate-sheet-rise">
|
||||
<div className="mx-auto mb-2 mt-1 h-1 w-10 rounded-full bg-gray-300" aria-hidden />
|
||||
|
||||
<SaveRow
|
||||
icon={<PhotoIcon className="w-6 h-6 text-primary-dark" />}
|
||||
label={t('gallery.save.photo')}
|
||||
hint={t('gallery.save.photoHint')}
|
||||
size={sizes.preview}
|
||||
state={rows.preview}
|
||||
statusLabels={t}
|
||||
onTap={() => onRowTap('preview')}
|
||||
/>
|
||||
<SaveRow
|
||||
icon={<ArrowDownTrayIcon className="w-6 h-6 text-primary-dark" />}
|
||||
label={t('gallery.save.original')}
|
||||
hint={t('gallery.save.originalHint')}
|
||||
size={sizes.original}
|
||||
state={rows.original}
|
||||
statusLabels={t}
|
||||
showProgress
|
||||
onTap={() => onRowTap('original')}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-1 w-full rounded-xl py-3 text-center font-medium text-gray-600 hover:bg-gray-50 active:bg-gray-100"
|
||||
>
|
||||
{t('gallery.save.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveRow({
|
||||
icon,
|
||||
label,
|
||||
hint,
|
||||
size,
|
||||
state,
|
||||
statusLabels,
|
||||
showProgress,
|
||||
onTap,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
hint: string;
|
||||
size: string | null;
|
||||
state: RowState;
|
||||
statusLabels: (key: string) => string;
|
||||
showProgress?: boolean;
|
||||
onTap: () => void;
|
||||
}) {
|
||||
const loading = state.status === 'loading';
|
||||
// The subtitle carries the row's state, so a slow fetch explains itself
|
||||
// without the row ever becoming untappable for good.
|
||||
const subtitle =
|
||||
state.status === 'loading'
|
||||
? statusLabels('gallery.save.preparing')
|
||||
: state.status === 'error'
|
||||
? statusLabels('gallery.save.failed')
|
||||
: [hint, size].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTap}
|
||||
aria-busy={loading}
|
||||
className="relative w-full overflow-hidden rounded-xl px-4 py-3 text-left flex items-center gap-3 hover:bg-gray-50 active:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow"
|
||||
>
|
||||
<span className="shrink-0">
|
||||
{loading ? (
|
||||
// Spinner defaults to the white ring used over photos; this one
|
||||
// sits on the sheet's white surface.
|
||||
<Spinner className="w-6 h-6 border-gray-300 border-t-primary-dark" />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-primary-dark">{label}</span>
|
||||
<span
|
||||
className={`block text-sm ${state.status === 'error' ? 'text-red-600' : 'text-gray-500'}`}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
</span>
|
||||
{showProgress && loading && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-1 bg-gray-200" aria-hidden>
|
||||
<span
|
||||
className="block h-full bg-primary-yellow transition-[width] duration-150"
|
||||
style={{ width: `${Math.round(state.progress * 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user