Files
Spanglish/frontend/src/components/gallery/SaveSheet.tsx
T
MichilisandCursor be4dd5b47f 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>
2026-08-06 20:14:31 +00:00

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