diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index f8c4cf66..51d8777e 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react'; import type { Photo } from '../../types'; -import { useDownloadPhoto } from '../../hooks/useGallery'; +import { useSavePhotoToDevice } from '../../hooks/useGallery'; import { AuthenticatedImage } from '../common'; import { PhotoFeedback } from './PhotoFeedback'; import { feedbackService } from '../../services/feedback.service'; @@ -99,7 +99,12 @@ export const PhotoLightbox: React.FC = ({ }, []); - const downloadPhotoMutation = useDownloadPhoto(); + // Save-aware download. On mobile (where Web Share + files is supported) + // this opens the OS share sheet so "Save to Photos" actually lands in + // the Photos/Gallery app — matters for non-technical clients who + // otherwise have to chain Files → unzip → save (#531). Desktop and + // unsupported browsers fall through to a regular . + const downloadPhotoMutation = useSavePhotoToDevice(); const currentPhoto = photos[currentIndex]; // DevTools protection - enabled by individual setting OR legacy protection level diff --git a/frontend/src/hooks/useGallery.ts b/frontend/src/hooks/useGallery.ts index 4f77c1ee..1e163ffc 100644 --- a/frontend/src/hooks/useGallery.ts +++ b/frontend/src/hooks/useGallery.ts @@ -65,6 +65,35 @@ export const useDownloadPhoto = () => { }); }; +// Save-aware download — opens the OS share sheet on mobile (so "Save to +// Photos" lands the file in the Photos/Gallery app instead of Files), +// falls back to a regular download on browsers without Web Share file +// support. See galleryService.savePhotoToDevice for the negotiation +// (#531). +// +// Toast omitted on success because the share-sheet path doesn't really +// finish from this code's perspective — the OS UI takes over and the +// user picks where it goes. Showing "Photo downloaded" before they've +// even picked is misleading. The fallback download path is also silent +// to keep the two paths symmetrical; the file appearing in Downloads +// is its own affordance. +export const useSavePhotoToDevice = () => { + return useMutation({ + mutationFn: ({ + slug, + photoId, + filename, + }: { + slug: string; + photoId: number; + filename: string; + }) => galleryService.savePhotoToDevice(slug, photoId, filename), + onError: () => { + toast.error('Failed to save photo'); + }, + }); +}; + export const useDownloadAllPhotos = () => { return useMutation({ mutationFn: ({ slug, zipReady }: { slug: string; zipReady?: boolean }) => diff --git a/frontend/src/services/gallery.service.ts b/frontend/src/services/gallery.service.ts index 061e3e52..2f47de0e 100644 --- a/frontend/src/services/gallery.service.ts +++ b/frontend/src/services/gallery.service.ts @@ -48,43 +48,106 @@ export const galleryService = { }; }, - // Download single photo - async downloadPhoto(slug: string, photoId: number, filename: string): Promise { - // Honour the server's Content-Disposition filename so the #493 - // "use original camera filename" toggle reaches disk for single - // downloads (it already worked for zips because those skip the - // `` attribute). Falls back to the caller-provided - // sanitized filename if the header is unreadable. - const downloadFromResponse = (response: { data: Blob; headers: Record }) => { + // Save single photo via the Web Share API on mobile, falling back to a + // regular browser download elsewhere (#531). + // + // On iOS Safari 15+ and Chrome Android the OS share sheet opened by + // navigator.share() includes "Save Image" / "Save to Photos", which + // is what non-technical clients actually want — straight into the + // Photos / Gallery app instead of the Files folder. Desktop browsers + // and Firefox don't implement Web Share File support, so they get the + // existing path (file lands in Downloads, same as before). + async savePhotoToDevice(slug: string, photoId: number, filename: string): Promise { + const fetched = await this.fetchPhotoBlob(slug, photoId); + const resolvedFilename = fetched.serverFilename || filename; + + // canShare() returns false on browsers without Web Share file support + // (desktop, older Safari, all Firefox as of writing). Probe with a + // representative File so the negotiation is accurate — `canShare({ + // files: [] })` returns true on some browsers that don't actually + // accept files at share() time. + const file = new File([fetched.blob], resolvedFilename, { + type: fetched.blob.type || 'image/jpeg', + }); + const canShareFile = + typeof navigator !== 'undefined' && + typeof navigator.canShare === 'function' && + navigator.canShare({ files: [file] }); + + if (canShareFile) { + try { + await navigator.share({ files: [file], title: resolvedFilename }); + return; + } catch (err) { + // AbortError = user dismissed the share sheet. Don't fall back — + // they made a choice. Any other failure (NotAllowedError, + // DataError, etc.) is unexpected; surface a download instead so + // the user still gets the file. + if ((err as DOMException)?.name === 'AbortError') return; + } + } + + this.triggerBrowserDownload(fetched.blob, resolvedFilename); + }, + + // Fetch the photo as a Blob + the server-suggested filename, falling + // back to the view endpoint when the original isn't available. Shared + // between the regular download flow and the Web Share path (#531). + // The server's Content-Disposition is the source of truth for the + // filename (#493 — "use original camera filename" toggle reaches disk + // through this header). + async fetchPhotoBlob( + slug: string, + photoId: number, + ): Promise<{ blob: Blob; serverFilename: string | null }> { + const readResponse = (response: { data: Blob; headers: Record }) => { const headerName = response.headers['content-disposition'] || response.headers['Content-Disposition']; - const serverFilename = parseContentDispositionFilename(headerName); - const url = window.URL.createObjectURL(new Blob([response.data])); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', serverFilename || filename); - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); + return { + blob: response.data, + serverFilename: parseContentDispositionFilename(headerName), + }; }; try { const response = await api.get(`/gallery/${slug}/download/${photoId}`, { responseType: 'blob', }); - downloadFromResponse(response); + return readResponse(response); } catch { - // Fallback: use the view endpoint if direct download fails (e.g., missing original). - // The view endpoint doesn't emit a download-oriented Content-Disposition, - // so we expect the caller-supplied filename to win here. + // Fallback: view endpoint when /download isn't available (e.g. + // the original is missing and only a derivative remains). The + // view endpoint doesn't emit a download-oriented Content-Disposition, + // so serverFilename will be null and the caller's name wins. const response = await api.get(`/gallery/${slug}/photo/${photoId}`, { responseType: 'blob', }); - downloadFromResponse(response); + return readResponse(response); } }, + // Trigger a regular browser download via a transient + // anchor. Extracted from downloadPhoto so the share-fallback path + // can reuse it without re-fetching the blob. + triggerBrowserDownload(blob: Blob, filename: string): void { + const url = window.URL.createObjectURL(new Blob([blob])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }, + + // Download single photo — kept as the canonical name for the existing + // grid + lightbox-action callers that haven't been migrated to the + // share-aware savePhotoToDevice path yet. + async downloadPhoto(slug: string, photoId: number, filename: string): Promise { + const fetched = await this.fetchPhotoBlob(slug, photoId); + this.triggerBrowserDownload(fetched.blob, fetched.serverFilename || filename); + }, + // Download all photos as ZIP // When a pre-generated zip is available, use native browser download (Content-Length → progress bar). // Otherwise fall back to blob download.