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.
This commit is contained in:
mauriceboe
2026-04-04 20:14:00 +02:00
parent 259ff53bfb
commit 11b6974387
2 changed files with 145 additions and 32 deletions

View File

@@ -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<number | null>(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 ? (
<button onClick={e => { e.stopPropagation(); onClick() }}
style={{
position: 'absolute', top: '50%', [side]: 12, transform: 'translateY(-50%)', zIndex: 10,
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
color: 'rgba(255,255,255,0.8)', transition: 'background 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.75)')}
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.5)')}>
{side === 'left' ? <ChevronLeft size={22} /> : <ChevronRight size={22} />}
</button>
) : null
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
onClick={onClose}
onTouchStart={e => 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)
}}
>
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<img
src={imgSrc}
alt={file.original_name}
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
/>
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={async () => { const u = await getAuthUrl(file.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}
title={t('files.openTab')}
>
<ExternalLink size={16} />
</button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
<X size={18} />
</button>
</div>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{file.original_name}
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button
onClick={async () => { const u = await getAuthUrl(file.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.openTab')}>
<ExternalLink size={16} />
</button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
<X size={18} />
</button>
</div>
</div>
{/* Main image + nav */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }} onClick={e => e.stopPropagation()}>
{navBtn('left', goPrev, hasPrev)}
{imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />}
{navBtn('right', goNext, hasNext)}
</div>
{/* Thumbnail strip */}
{files.length > 1 && (
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }} onClick={e => e.stopPropagation()}>
{files.map((f, i) => (
<ThumbImg key={f.id} file={f} active={i === index} onClick={() => setIndex(i)} />
))}
</div>
)}
</div>
)
}
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 (
<button onClick={onClick} style={{
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
}}>
{src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
</button>
)
}
// 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<number | null>(null)
const [showTrash, setShowTrash] = useState(false)
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
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 (
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
{/* Lightbox */}
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
{/* Assign modal */}
{assignFileId && ReactDOM.createPortal(

View File

@@ -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 (
<div onClick={closeLightbox}
onKeyDown={e => { 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
<X size={20} color="white" />
</button>
{/* Counter */}
{allVisible.length > 1 && (
<div style={{ position: 'absolute', top: 20, left: 20, zIndex: 10, fontSize: 12, color: 'rgba(255,255,255,0.5)' }}>
{currentIdx + 1} / {allVisible.length}
</div>
)}
{/* Prev/Next buttons */}
{hasPrev && (
<button onClick={e => { e.stopPropagation(); navigateTo(currentIdx - 1) }}
style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', zIndex: 10, background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'rgba(255,255,255,0.8)' }}>
<ChevronLeft size={22} />
</button>
)}
{hasNext && (
<button onClick={e => { e.stopPropagation(); navigateTo(currentIdx + 1) }}
style={{ position: 'absolute', right: isMobile ? 12 : 280, top: '50%', transform: 'translateY(-50%)', zIndex: 10, background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'rgba(255,255,255,0.8)' }}>
<ChevronRight size={22} />
</button>
)}
{/* Mobile info toggle button */}
{isMobile && (lightboxInfo || lightboxInfoLoading) && (
<button onClick={e => { e.stopPropagation(); setShowMobileInfo(prev => !prev) }}