diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 3dc6044..dbaefa7 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,121 @@ 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 */} +
{ if (e.target === e.currentTarget) onClose() }}> + {navBtn('left', goPrev, hasPrev)} + {imgSrc && {file.original_name} e.stopPropagation()} />} + {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 +241,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 +396,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 +528,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/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index a3ff1bb..32d1ec4 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -161,12 +161,13 @@ function MapController({ center, zoom }: MapControllerProps) { // Fit bounds when places change (fitKey triggers re-fit) interface BoundsControllerProps { + hasDayDetail?: boolean places: Place[] fitKey: number paddingOpts: Record } -function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps) { +function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) { const map = useMap() const prevFitKey = useRef(-1) @@ -176,9 +177,14 @@ function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps if (places.length === 0) return try { const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng])) - if (bounds.isValid()) map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true }) + if (bounds.isValid()) { + map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true }) + if (hasDayDetail) { + setTimeout(() => map.panBy([0, 150], { animate: true }), 300) + } + } } catch {} - }, [fitKey, places, paddingOpts, map]) + }, [fitKey, places, paddingOpts, map, hasDayDetail]) return null } @@ -377,17 +383,18 @@ export const MapView = memo(function MapView({ leftWidth = 0, rightWidth = 0, hasInspector = false, + hasDayDetail = false, }) { - // Dynamic padding: account for sidebars + bottom inspector + // Dynamic padding: account for sidebars + bottom inspector + day detail panel const paddingOpts = useMemo(() => { const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 if (isMobile) return { padding: [40, 20] } const top = 60 - const bottom = hasInspector ? 320 : 60 + const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60 const left = leftWidth + 40 const right = rightWidth + 40 return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] } - }, [leftWidth, rightWidth, hasInspector]) + }, [leftWidth, rightWidth, hasInspector, hasDayDetail]) // photoUrls: only base64 thumbs for smooth map zoom const [photoUrls, setPhotoUrls] = useState>(getAllThumbs) @@ -509,7 +516,7 @@ export const MapView = memo(function MapView({ /> - 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} /> + 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} /> diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 3d11886..5b5357d 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) && ( )} -
e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}> +
{ if (e.target === e.currentTarget) closeLightbox() }} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}> e.stopPropagation()} style={{ maxWidth: (!isMobile && lightboxInfo) ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }} /> diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index 243ba34..aa6abf9 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -775,10 +775,25 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp } } + // Parse CSV line respecting quoted values (e.g. "Shirt, blue" stays as one field) + const parseCsvLine = (line: string): string[] => { + const parts: string[] = [] + let current = '' + let inQuotes = false + for (let i = 0; i < line.length; i++) { + const ch = line[i] + if (ch === '"') { inQuotes = !inQuotes; continue } + if (!inQuotes && (ch === ',' || ch === ';' || ch === '\t')) { parts.push(current.trim()); current = ''; continue } + current += ch + } + parts.push(current.trim()) + return parts + } + const parseImportLines = (text: string) => { return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => { // Format: Category, Name, Weight (optional), Bag (optional), checked/unchecked (optional) - const parts = line.split(/[,;\t]/).map(s => s.trim()) + const parts = parseCsvLine(line) if (parts.length >= 2) { const category = parts[0] const name = parts[1] @@ -1187,18 +1202,29 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp }} onClick={e => e.stopPropagation()}>
{t('packing.importTitle')}
{t('packing.importHint')}
-