'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 `` 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--.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 { // 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 ; } function SaveSheetPanel({ photo, onClose }: { photo: SavePhoto; onClose: () => void }) { const { t, locale } = useLanguage(); const [rows, setRows] = useState>({ 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>>({}); const aborter = useRef(new AbortController()); const closed = useRef(false); const setRow = useCallback((key: RowKey, patch: Partial) => { if (closed.current) return; setRows((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } })); }, []); const prepare = useCallback( async (key: RowKey): Promise => { 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 (
); } 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 ( ); }