import { useState, useEffect, useCallback } from 'react' import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen } from 'lucide-react' import apiClient from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' import { getAuthUrl, fetchImageAsBlob, clearImageQueue } from '../../api/authUrl' import { useToast } from '../shared/Toast' function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { const [src, setSrc] = useState('') useEffect(() => { let revoke = '' fetchImageAsBlob(baseUrl).then(blobUrl => { revoke = blobUrl setSrc(blobUrl) }) return () => { if (revoke) URL.revokeObjectURL(revoke) } }, [baseUrl]) return src ? : null } // ── Types ─────────────────────────────────────────────────────────────────── interface TripPhoto { immich_asset_id: string user_id: number username: string shared: number added_at: string } interface ImmichAsset { id: string takenAt: string city: string | null country: string | null } interface MemoriesPanelProps { tripId: number startDate: string | null endDate: string | null } // ── Main Component ────────────────────────────────────────────────────────── export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) { const { t } = useTranslation() const toast = useToast() const currentUser = useAuthStore(s => s.user) const [connected, setConnected] = useState(false) const [loading, setLoading] = useState(true) // Trip photos (saved selections) const [tripPhotos, setTripPhotos] = useState([]) // Photo picker const [showPicker, setShowPicker] = useState(false) const [pickerPhotos, setPickerPhotos] = useState([]) const [pickerLoading, setPickerLoading] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) // Confirm share popup const [showConfirmShare, setShowConfirmShare] = useState(false) // Filters & sort const [sortAsc, setSortAsc] = useState(true) const [locationFilter, setLocationFilter] = useState('') // Album linking const [showAlbumPicker, setShowAlbumPicker] = useState(false) const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([]) const [albumsLoading, setAlbumsLoading] = useState(false) const [albumLinks, setAlbumLinks] = useState<{ id: number; immich_album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) const [syncing, setSyncing] = useState(null) const loadAlbumLinks = async () => { try { const res = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`) setAlbumLinks(res.data.links || []) } catch { setAlbumLinks([]) } } const openAlbumPicker = async () => { setShowAlbumPicker(true) setAlbumsLoading(true) try { const res = await apiClient.get('/integrations/immich/albums') setAlbums(res.data.albums || []) } catch { setAlbums([]); toast.error(t('memories.error.loadAlbums')) } finally { setAlbumsLoading(false) } } const linkAlbum = async (albumId: string, albumName: string) => { try { await apiClient.post(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName }) setShowAlbumPicker(false) await loadAlbumLinks() // Auto-sync after linking const linksRes = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`) const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId) if (newLink) await syncAlbum(newLink.id) } catch { toast.error(t('memories.error.linkAlbum')) } } const unlinkAlbum = async (linkId: number) => { try { await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`) await loadAlbumLinks() await loadPhotos() } catch { toast.error(t('memories.error.unlinkAlbum')) } } const syncAlbum = async (linkId: number) => { setSyncing(linkId) try { await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`) await loadAlbumLinks() await loadPhotos() } catch { toast.error(t('memories.error.syncAlbum')) } finally { setSyncing(null) } } // Lightbox const [lightboxId, setLightboxId] = useState(null) const [lightboxUserId, setLightboxUserId] = useState(null) const [lightboxInfo, setLightboxInfo] = useState(null) const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false) const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('') // ── Init ────────────────────────────────────────────────────────────────── useEffect(() => { loadInitial() }, [tripId]) // WebSocket: reload photos when another user adds/removes/shares useEffect(() => { const handler = () => loadPhotos() window.addEventListener('memories:updated', handler) return () => window.removeEventListener('memories:updated', handler) }, [tripId]) const loadPhotos = async () => { try { const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`) setTripPhotos(photosRes.data.photos || []) } catch { setTripPhotos([]) } } const loadInitial = async () => { setLoading(true) try { const statusRes = await apiClient.get('/integrations/immich/status') setConnected(statusRes.data.connected) } catch { setConnected(false) } await loadPhotos() await loadAlbumLinks() setLoading(false) } // ── Photo Picker ────────────────────────────────────────────────────────── const [pickerDateFilter, setPickerDateFilter] = useState(true) const openPicker = async () => { setShowPicker(true) setPickerLoading(true) setSelectedIds(new Set()) setPickerDateFilter(!!(startDate && endDate)) await loadPickerPhotos(!!(startDate && endDate)) } const loadPickerPhotos = async (useDate: boolean) => { setPickerLoading(true) try { const res = await apiClient.post('/integrations/immich/search', { from: useDate && startDate ? startDate : undefined, to: useDate && endDate ? endDate : undefined, }) setPickerPhotos(res.data.assets || []) } catch { setPickerPhotos([]) toast.error(t('memories.error.loadPhotos')) } finally { setPickerLoading(false) } } const togglePickerSelect = (id: string) => { setSelectedIds(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } const confirmSelection = () => { if (selectedIds.size === 0) return setShowConfirmShare(true) } const executeAddPhotos = async () => { setShowConfirmShare(false) try { await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, { asset_ids: [...selectedIds], shared: true, }) setShowPicker(false) clearImageQueue() loadInitial() } catch { toast.error(t('memories.error.addPhotos')) } } // ── Remove photo ────────────────────────────────────────────────────────── const removePhoto = async (assetId: string) => { try { await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`) setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId)) } catch { toast.error(t('memories.error.removePhoto')) } } // ── Toggle sharing ──────────────────────────────────────────────────────── const toggleSharing = async (assetId: string, shared: boolean) => { try { await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared }) setTripPhotos(prev => prev.map(p => p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p )) } catch { toast.error(t('memories.error.toggleSharing')) } } // ── Helpers ─────────────────────────────────────────────────────────────── const thumbnailBaseUrl = (assetId: string, userId: number) => `/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}` const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id) const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared) const allVisibleRaw = [...ownPhotos, ...othersPhotos] // Unique locations for filter const locations = [...new Set(allVisibleRaw.map(p => p.city).filter(Boolean) as string[])].sort() // Apply filter + sort const allVisible = allVisibleRaw .filter(p => !locationFilter || p.city === locationFilter) .sort((a, b) => { const da = new Date(a.added_at || 0).getTime() const db = new Date(b.added_at || 0).getTime() return sortAsc ? da - db : db - da }) const font: React.CSSProperties = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", } // ── Loading ─────────────────────────────────────────────────────────────── if (loading) { return (
) } // ── Not connected ───────────────────────────────────────────────────────── if (!connected && allVisible.length === 0) { return (

{t('memories.notConnected')}

{t('memories.notConnectedHint')}

) } // ── Photo Picker Modal ──────────────────────────────────────────────────── // ── Album Picker Modal ────────────────────────────────────────────────── if (showAlbumPicker) { const linkedIds = new Set(albumLinks.map(l => l.immich_album_id)) return (

{t('memories.selectAlbum')}

{albumsLoading ? (
) : albums.length === 0 ? (

{t('memories.noAlbums')}

) : (
{albums.map(album => { const isLinked = linkedIds.has(album.id) return ( ) })}
)}
) } // ── Photo Picker Modal ──────────────────────────────────────────────────── if (showPicker) { const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id)) return ( <>
{/* Picker header */}

{t('memories.selectPhotos')}

{/* Filter tabs */}
{startDate && endDate && ( )}
{selectedIds.size > 0 && (

{selectedIds.size} {t('memories.selected')}

)}
{/* Picker grid */}
{pickerLoading ? (
) : pickerPhotos.length === 0 ? (

{t('memories.noPhotos')}

) : (() => { // Group photos by month const byMonth: Record = {} for (const asset of pickerPhotos) { const d = asset.takenAt ? new Date(asset.takenAt) : null const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown' if (!byMonth[key]) byMonth[key] = [] byMonth[key].push(asset) } const sortedMonths = Object.keys(byMonth).sort().reverse() return sortedMonths.map(month => (
{month !== 'unknown' ? new Date(month + '-15').toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) : '—'}
{byMonth[month].map(asset => { const isSelected = selectedIds.has(asset.id) const isAlready = alreadyAdded.has(asset.id) return (
!isAlready && togglePickerSelect(asset.id)} style={{ position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden', cursor: isAlready ? 'default' : 'pointer', opacity: isAlready ? 0.3 : 1, outline: isSelected ? '3px solid var(--text-primary)' : 'none', outlineOffset: -3, }}> {isSelected && (
)} {isAlready && (
{t('memories.alreadyAdded')}
)}
) })}
)) })()}
{/* Confirm share popup (inside picker) */} {showConfirmShare && (
setShowConfirmShare(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
e.stopPropagation()} style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>

{t('memories.confirmShareTitle')}

{t('memories.confirmShareHint', { count: selectedIds.size })}

)} ) } // ── Main Gallery ────────────────────────────────────────────────────────── return (
{/* Header */}

{t('memories.title')}

{allVisible.length} {t('memories.photosFound')} {othersPhotos.length > 0 && ` · ${othersPhotos.length} ${t('memories.fromOthers')}`}

{connected && (
)}
{/* Linked Albums */} {albumLinks.length > 0 && (
{albumLinks.map(link => (
{link.album_name} {link.username !== currentUser?.username && ({link.username})} {link.user_id === currentUser?.id && ( )}
))}
)}
{/* Filter & Sort bar */} {allVisibleRaw.length > 0 && (
{locations.length > 1 && ( )}
)} {/* Gallery */}
{allVisible.length === 0 ? (

{t('memories.noPhotos')}

{t('memories.noPhotosHint')}

) : (
{allVisible.map(photo => { const isOwn = photo.user_id === currentUser?.id return (
{ setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) setLightboxOriginalSrc('') fetchImageAsBlob(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`).then(setLightboxOriginalSrc) setLightboxInfoLoading(true) apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`) .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) }}> {/* Other user's avatar */} {!isOwn && (
{photo.username[0]}
{photo.username}
)} {/* Own photo actions (hover) */} {isOwn && (
)} {/* Not shared indicator */} {isOwn && !photo.shared && (
{t('memories.private')}
)}
) })}
)}
{/* Confirm share popup */} {showConfirmShare && (
setShowConfirmShare(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
e.stopPropagation()} style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>

{t('memories.confirmShareTitle')}

{t('memories.confirmShareHint', { count: selectedIds.size })}

)} {/* Lightbox */} {lightboxId && lightboxUserId && (
{ if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); setLightboxId(null); setLightboxUserId(null) }} style={{ position: 'absolute', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center', }}>
e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}> {/* Info panel — liquid glass */} {lightboxInfo && (
{/* Date */} {lightboxInfo.takenAt && (
Date
{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}
{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
)} {/* Location */} {(lightboxInfo.city || lightboxInfo.country) && (
Location
{[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')}
)} {/* Camera */} {lightboxInfo.camera && (
Camera
{lightboxInfo.camera}
{lightboxInfo.lens &&
{lightboxInfo.lens}
}
)} {/* Settings */} {(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && (
{lightboxInfo.focalLength && (
Focal
{lightboxInfo.focalLength}
)} {lightboxInfo.aperture && (
Aperture
{lightboxInfo.aperture}
)} {lightboxInfo.shutter && (
Shutter
{lightboxInfo.shutter}
)} {lightboxInfo.iso && (
ISO
{lightboxInfo.iso}
)}
)} {/* Resolution & File */} {(lightboxInfo.width || lightboxInfo.fileName) && (
{lightboxInfo.width && lightboxInfo.height && (
{lightboxInfo.width} × {lightboxInfo.height}
)} {lightboxInfo.fileSize && (
{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB
)}
)}
)} {lightboxInfoLoading && (
)}
)}
) }