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>
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
'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>
|
|
);
|
|
}
|