import { useState, useEffect, useCallback } from 'react' import apiClient, { addonsApi } from '../../api/client' import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen, Info, ChevronLeft, ChevronRight } from 'lucide-react' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' import { fetchImageAsBlob, clearImageQueue } from '../../api/authUrl' import { useToast } from '../shared/Toast' interface PhotoProvider { id: string name: string icon?: string config?: Record } function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; provider: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { const [src, setSrc] = useState('') useEffect(() => { let revoke = '' fetchImageAsBlob('/api' + baseUrl).then(blobUrl => { revoke = blobUrl setSrc(blobUrl) }) return () => { if (revoke) URL.revokeObjectURL(revoke) } }, [baseUrl]) return src ? : null } // ── Types ─────────────────────────────────────────────────────────────────── interface TripPhoto { photo_id: number asset_id: string provider: string user_id: number username: string shared: number added_at: string city?: string | null } interface Asset { id: string provider: 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 [enabledProviders, setEnabledProviders] = useState([]) const [availableProviders, setAvailableProviders] = useState([]) const [selectedProvider, setSelectedProvider] = useState('') 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; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) const [syncing, setSyncing] = useState(null) //helpers for building urls const ADDON_PREFIX = "/integrations/memories" function buildUnifiedUrl(endpoint: string, lastParam?:string,): string { return `${ADDON_PREFIX}/unified/trips/${tripId}/${endpoint}${lastParam ? `/${lastParam}` : ''}`; } function buildProviderUrl(provider: string, endpoint: string, item?: string): string { if (endpoint === 'album-link-sync') { endpoint = `trips/${tripId}/album-links/${item?.toString() || ''}/sync` } return `${ADDON_PREFIX}/${provider}/${endpoint}`; } function buildProviderAssetUrl(photo: TripPhoto, what: string): string { return `/photos/${photo.photo_id}/${what}` } function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string { // Picker photos are not yet saved — use provider-specific URL return `${ADDON_PREFIX}/${asset.provider}/assets/${tripId}/${asset.id}/${userId}/${what}` } const loadAlbumLinks = async () => { try { const res = await apiClient.get(buildUnifiedUrl('album-links')) setAlbumLinks(res.data.links || []) } catch { setAlbumLinks([]) } } const loadAlbums = async (provider: string = selectedProvider) => { if (!provider) return setAlbumsLoading(true) try { const res = await apiClient.get(buildProviderUrl(provider, 'albums')) setAlbums(res.data.albums || []) } catch { setAlbums([]) toast.error(t('memories.error.loadAlbums')) } finally { setAlbumsLoading(false) } } const openAlbumPicker = async () => { setShowAlbumPicker(true) await loadAlbums(selectedProvider) } const linkAlbum = async (albumId: string, albumName: string) => { if (!selectedProvider) { toast.error(t('memories.error.linkAlbum')) return } try { await apiClient.post(buildUnifiedUrl('album-links'), { album_id: albumId, album_name: albumName, provider: selectedProvider, }) setShowAlbumPicker(false) await loadAlbumLinks() // Auto-sync after linking const linksRes = await apiClient.get(buildUnifiedUrl('album-links')) const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider) if (newLink) await syncAlbum(newLink.id) } catch { toast.error(t('memories.error.linkAlbum')) } } const unlinkAlbum = async (linkId: number) => { try { await apiClient.delete(buildUnifiedUrl('album-links', linkId.toString())) await loadAlbumLinks() await loadPhotos() } catch { toast.error(t('memories.error.unlinkAlbum')) } } const syncAlbum = async (linkId: number, provider?: string) => { const targetProvider = provider || selectedProvider if (!targetProvider) return setSyncing(linkId) try { await apiClient.post(buildProviderUrl(targetProvider, 'album-link-sync', linkId.toString())) 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('') const [showMobileInfo, setShowMobileInfo] = useState(false) const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768) useEffect(() => { const handleResize = () => setIsMobile(window.innerWidth < 768) window.addEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize) }, []) // ── 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(buildUnifiedUrl('photos')) setTripPhotos(photosRes.data.photos || []) } catch { setTripPhotos([]) } } const loadInitial = async () => { setLoading(true) try { const addonsRes = await addonsApi.enabled().catch(() => ({ addons: [] as any[] })) const enabledAddons = addonsRes?.addons || [] const photoProviders = enabledAddons.filter((a: any) => a.type === 'photo_provider' && a.enabled) setEnabledProviders(photoProviders.map((a: any) => ({ id: a.id, name: a.name, icon: a.icon, config: a.config }))) // Test connection status for each enabled provider const statusResults = await Promise.all( photoProviders.map(async (provider: any) => { const statusUrl = (provider.config as Record)?.status_get as string if (!statusUrl) return { provider, connected: false } try { const res = await apiClient.get(statusUrl) return { provider, connected: !!res.data?.connected } } catch { return { provider, connected: false } } }) ) const connectedProviders = statusResults .filter(r => r.connected) .map(r => ({ id: r.provider.id, name: r.provider.name, icon: r.provider.icon, config: r.provider.config })) setAvailableProviders(connectedProviders) setConnected(connectedProviders.length > 0) if (connectedProviders.length > 0 && !selectedProvider) { setSelectedProvider(connectedProviders[0].id) } } catch { setAvailableProviders([]) 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)) } useEffect(() => { if (showPicker) { loadPickerPhotos(pickerDateFilter) } }, [selectedProvider]) useEffect(() => { loadAlbumLinks() }, [tripId]) useEffect(() => { if (showAlbumPicker) { loadAlbums(selectedProvider) } }, [showAlbumPicker, selectedProvider, tripId]) const loadPickerPhotos = async (useDate: boolean) => { setPickerLoading(true) try { const provider = availableProviders.find(p => p.id === selectedProvider) if (!provider) { setPickerPhotos([]) return } const res = await apiClient.post(buildProviderUrl(provider.id, 'search'), { from: useDate && startDate ? startDate : undefined, to: useDate && endDate ? endDate : undefined, }) setPickerPhotos((res.data.assets || []).map((asset: Asset) => ({ ...asset, provider: provider.id }))) } 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 { const groupedByProvider = new Map() for (const key of selectedIds) { const [provider, assetId] = key.split('::') if (!provider || !assetId) continue const list = groupedByProvider.get(provider) || [] list.push(assetId) groupedByProvider.set(provider, list) } await apiClient.post(buildUnifiedUrl('photos'), { selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })), shared: true, }) setShowPicker(false) clearImageQueue() loadInitial() } catch { toast.error(t('memories.error.addPhotos')) } } // ── Remove photo ────────────────────────────────────────────────────────── const removePhoto = async (photo: TripPhoto) => { try { await apiClient.delete(buildUnifiedUrl('photos'), { data: { photo_id: photo.photo_id, }, }) setTripPhotos(prev => prev.filter(p => p.photo_id !== photo.photo_id)) } catch { toast.error(t('memories.error.removePhoto')) } } // ── Toggle sharing ──────────────────────────────────────────────────────── const toggleSharing = async (photo: TripPhoto, shared: boolean) => { try { await apiClient.put(buildUnifiedUrl('photos', 'sharing'), { shared, photo_id: photo.photo_id, }) setTripPhotos(prev => prev.map(p => p.photo_id === photo.photo_id ? { ...p, shared: shared ? 1 : 0 } : p )) } catch { toast.error(t('memories.error.toggleSharing')) } } // ── Helpers ─────────────────────────────────────────────────────────────── const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}` 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', { provider_name: enabledProviders.length === 1 ? enabledProviders[0]?.name : 'Photo provider' })}

{enabledProviders.length === 1 ? t('memories.notConnectedHint', { provider_name: enabledProviders[0]?.name }) : t('memories.notConnectedMultipleHint', { provider_names: enabledProviders.map(p => p.name).join(', ') })}

) } // ── Photo Picker Modal ──────────────────────────────────────────────────── const ProviderTabs = () => { if (availableProviders.length < 2) return null return (
{availableProviders.map(provider => ( ))}
) } // ── Album Picker Modal ────────────────────────────────────────────────── if (showAlbumPicker) { const linkedIds = new Set(albumLinks.map(l => l.album_id)) return (

{availableProviders.length > 1 ? t('memories.selectAlbumMultiple') : t('memories.selectAlbum', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}

{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 => makePickerKey(p.provider, p.asset_id)) ) return ( <>
{/* Picker header */}

{availableProviders.length > 1 ? t('memories.selectPhotosMultiple') : t('memories.selectPhotos', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}

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

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

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

{t('memories.noPhotos')}

{ pickerDateFilter && (

{t('memories.noPhotosHint', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}

) }
) : (() => { // 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 pickerKey = makePickerKey(asset.provider, asset.id) const isSelected = selectedIds.has(pickerKey) const isAlready = alreadyAdded.has(pickerKey) return (
!isAlready && togglePickerSelect(pickerKey)} 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 (
{/* Disconnected banner — shown when photos exist but provider is unreachable */} {!connected && allVisible.length > 0 && enabledProviders.length > 0 && (
{t('memories.providerDisconnectedBanner', { provider_name: enabledProviders.length === 1 ? enabledProviders[0].name : enabledProviders.map(p => p.name).join(', ') })}
)} {/* 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')}

) : (
{allVisible.map(photo => { const isOwn = photo.user_id === currentUser?.id return (
{ setLightboxId(photo.photo_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) setLightboxOriginalSrc('') fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) setLightboxInfoLoading(true) apiClient.get(buildProviderAssetUrl(photo, 'info')) .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 && (() => { const closeLightbox = () => { if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) setLightboxOriginalSrc('') setLightboxId(null) setLightboxUserId(null) setShowMobileInfo(false) } const currentIdx = allVisible.findIndex(p => p.photo_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.photo_id) setLightboxUserId(photo.user_id) setLightboxInfo(null) fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) setLightboxInfoLoading(true) apiClient.get(buildProviderAssetUrl(photo, 'info')) .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) } const exifContent = lightboxInfo ? ( <> {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' })}
)} {(lightboxInfo.city || lightboxInfo.country) && (
Location
{[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')}
)} {lightboxInfo.camera && (
Camera
{lightboxInfo.camera}
{lightboxInfo.lens &&
{lightboxInfo.lens}
}
)} {(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}
)}
)} {(lightboxInfo.width || lightboxInfo.fileName) && (
{lightboxInfo.width && lightboxInfo.height && (
{lightboxInfo.width} × {lightboxInfo.height}
)} {lightboxInfo.fileSize && (
{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB
)}
)} ) : null 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, outline: 'none', background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center', }}> {/* Close button */} {/* Counter */} {allVisible.length > 1 && (
{currentIdx + 1} / {allVisible.length}
)} {/* Prev/Next buttons */} {hasPrev && ( )} {hasNext && ( )} {/* Mobile info toggle button */} {isMobile && (lightboxInfo || lightboxInfoLoading) && ( )}
{ 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' }} /> {/* Desktop info panel — liquid glass */} {!isMobile && lightboxInfo && (
{exifContent}
)} {!isMobile && lightboxInfoLoading && (
)}
{/* Mobile bottom sheet */} {isMobile && showMobileInfo && lightboxInfo && (
e.stopPropagation()} style={{ position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 5, maxHeight: '60vh', overflowY: 'auto', borderRadius: '16px 16px 0 0', padding: 18, background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', border: '1px solid rgba(255,255,255,0.12)', borderBottom: 'none', color: 'white', display: 'flex', flexDirection: 'column', gap: 14, }}> {exifContent}
)}
) })()}
) }