From 11b6974387402d2f00dd09bf6087b8bf9dc3ea72 Mon Sep 17 00:00:00 2001 From: mauriceboe Date: Sat, 4 Apr 2026 20:14:00 +0200 Subject: [PATCH] feat(files,memories): add gallery navigation to image lightboxes Files lightbox: prev/next buttons, keyboard arrows, swipe on mobile, thumbnail strip, file counter. Navigates between all images in the current filtered view. Memories lightbox: prev/next buttons, keyboard arrows, swipe on mobile, photo counter. Navigates between all visible trip photos. --- client/src/components/Files/FileManager.tsx | 134 ++++++++++++++---- .../src/components/Memories/MemoriesPanel.tsx | 43 +++++- 2 files changed, 145 insertions(+), 32 deletions(-) diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 3dc6044..e27b53f 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -1,7 +1,7 @@ import ReactDOM from 'react-dom' import { useState, useCallback, useRef, useEffect } from 'react' import { useDropzone } from 'react-dropzone' -import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react' +import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { filesApi } from '../../api/client' @@ -37,49 +37,120 @@ function formatDateWithLocale(dateStr, locale) { } catch { return '' } } -// Image lightbox +// Image lightbox with gallery navigation interface ImageLightboxProps { - file: TripFile & { url: string } + files: (TripFile & { url: string })[] + initialIndex: number onClose: () => void } -function ImageLightbox({ file, onClose }: ImageLightboxProps) { +function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) { const { t } = useTranslation() + const [index, setIndex] = useState(initialIndex) const [imgSrc, setImgSrc] = useState('') + const [touchStart, setTouchStart] = useState(null) + const file = files[index] + useEffect(() => { - getAuthUrl(file.url, 'download').then(setImgSrc) - }, [file.url]) + setImgSrc('') + if (file) getAuthUrl(file.url, 'download').then(setImgSrc) + }, [file?.url]) + + const goPrev = () => setIndex(i => Math.max(0, i - 1)) + const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1)) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + if (e.key === 'ArrowLeft') goPrev() + if (e.key === 'ArrowRight') goNext() + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + + if (!file) return null + + const hasPrev = index > 0 + const hasNext = index < files.length - 1 + const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? ( + + ) : null + return (
setTouchStart(e.touches[0].clientX)} + onTouchEnd={e => { + if (touchStart === null) return + const diff = e.changedTouches[0].clientX - touchStart + if (diff > 60) goPrev() + else if (diff < -60) goNext() + setTouchStart(null) + }} > -
e.stopPropagation()}> - {file.original_name} -
- {file.original_name} -
- - -
+ {/* Header */} +
e.stopPropagation()}> + + {file.original_name} + {index + 1} / {files.length} + +
+ +
+ + {/* Main image + nav */} +
e.stopPropagation()}> + {navBtn('left', goPrev, hasPrev)} + {imgSrc && {file.original_name}} + {navBtn('right', goNext, hasNext)} +
+ + {/* Thumbnail strip */} + {files.length > 1 && ( +
e.stopPropagation()}> + {files.map((f, i) => ( + setIndex(i)} /> + ))} +
+ )}
) } +function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) { + const [src, setSrc] = useState('') + useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url]) + return ( + + ) +} + // Authenticated image — fetches a short-lived download token and renders the image function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) { const [authSrc, setAuthSrc] = useState('') @@ -169,7 +240,7 @@ interface FileManagerProps { export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) { const [uploading, setUploading] = useState(false) const [filterType, setFilterType] = useState('all') - const [lightboxFile, setLightboxFile] = useState(null) + const [lightboxIndex, setLightboxIndex] = useState(null) const [showTrash, setShowTrash] = useState(false) const [trashFiles, setTrashFiles] = useState([]) const [loadingTrash, setLoadingTrash] = useState(false) @@ -324,9 +395,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, } } + const imageFiles = filteredFiles.filter(f => isImage(f.mime_type)) + const openFile = (file) => { if (isImage(file.mime_type)) { - setLightboxFile(file) + const idx = imageFiles.findIndex(f => f.id === file.id) + setLightboxIndex(idx >= 0 ? idx : 0) } else { setPreviewFile(file) } @@ -453,7 +527,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, return (
{/* Lightbox */} - {lightboxFile && setLightboxFile(null)} />} + {lightboxIndex !== null && setLightboxIndex(null)} />} {/* Assign modal */} {assignFileId && ReactDOM.createPortal( diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 3d11886..7cf18cb 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen, Info } from 'lucide-react' +import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen, Info, ChevronLeft, ChevronRight } from 'lucide-react' import apiClient from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' @@ -767,6 +767,20 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setShowMobileInfo(false) } + const currentIdx = allVisible.findIndex(p => p.immich_asset_id === lightboxId) + const hasPrev = currentIdx > 0 + const hasNext = currentIdx < allVisible.length - 1 + const navigateTo = (idx: number) => { + const photo = allVisible[idx] + if (!photo) return + if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) + setLightboxOriginalSrc('') + setLightboxId(photo.immich_asset_id) + setLightboxUserId(photo.user_id) + setLightboxInfo(null) + fetchImageAsBlob(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`).then(setLightboxOriginalSrc) + } + const exifContent = lightboxInfo ? ( <> {lightboxInfo.takenAt && ( @@ -836,8 +850,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa return (
{ if (e.key === 'ArrowLeft' && hasPrev) navigateTo(currentIdx - 1); if (e.key === 'ArrowRight' && hasNext) navigateTo(currentIdx + 1); if (e.key === 'Escape') closeLightbox() }} + tabIndex={0} ref={el => el?.focus()} + onTouchStart={e => (e.currentTarget as any)._touchX = e.touches[0].clientX} + onTouchEnd={e => { const start = (e.currentTarget as any)._touchX; if (start == null) return; const diff = e.changedTouches[0].clientX - start; if (diff > 60 && hasPrev) navigateTo(currentIdx - 1); else if (diff < -60 && hasNext) navigateTo(currentIdx + 1) }} style={{ - position: 'absolute', inset: 0, zIndex: 100, + position: 'absolute', inset: 0, zIndex: 100, outline: 'none', background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center', }}> {/* Close button */} @@ -850,6 +868,27 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa + {/* Counter */} + {allVisible.length > 1 && ( +
+ {currentIdx + 1} / {allVisible.length} +
+ )} + + {/* Prev/Next buttons */} + {hasPrev && ( + + )} + {hasNext && ( + + )} + {/* Mobile info toggle button */} {isMobile && (lightboxInfo || lightboxInfoLoading) && (