diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx
index a0cbd3d..e1f117b 100644
--- a/client/src/components/Budget/BudgetPanel.tsx
+++ b/client/src/components/Budget/BudgetPanel.tsx
@@ -633,7 +633,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
- handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
+ handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
{/* Mobile: larger chips under name since Persons column is hidden */}
{hasMultipleMembers && (
diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx
index 933ca84..3f8aef7 100644
--- a/client/src/components/Collab/CollabNotes.tsx
+++ b/client/src/components/Collab/CollabNotes.tsx
@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import DOM from 'react-dom'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
-import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
+import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
import { collabApi } from '../../api/client'
import { getAuthUrl } from '../../api/authUrl'
import { useCanDo } from '../../store/permissionsStore'
@@ -100,6 +100,7 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
const [authUrl, setAuthUrl] = useState('')
const rawUrl = file?.url || ''
useEffect(() => {
+ setAuthUrl('')
if (!rawUrl) return
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
}, [rawUrl])
@@ -119,7 +120,10 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
{isImage ? (
/* Image lightbox — floating controls */
e.stopPropagation()}>
- 
+ {authUrl
+ ? 
+ :
+ }
{file.original_name}
@@ -487,7 +491,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
const isImage = a.mime_type?.startsWith('image/')
return (
- {isImage &&  }
+ {isImage && }
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
{viewingNote.content || ''}
+ {(viewingNote.attachments || []).length > 0 && (
+
+ {t('files.title')}
+
+ {(viewingNote.attachments || []).map(a => {
+ const isImage = a.mime_type?.startsWith('image/')
+ const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
+ return (
+
+ {isImage ? (
+ setPreviewFile(a)}
+ onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
+ onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
+ ) : (
+ setPreviewFile(a)}
+ style={{
+ width: 64, height: 64, borderRadius: 8, cursor: 'pointer',
+ background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
+ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
+ transition: 'transform 0.12s, box-shadow 0.12s',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
+ onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
+ {ext}
+
+ )}
+ {a.original_name}
+
+ )
+ })}
+
+
+ )}
,
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}
-
-
-
-
+ {/* Header */}
+ e.stopPropagation()}>
+
+ {file.original_name}
+ {index + 1} / {files.length}
+
+
+
+
+
+ {/* Main image + nav */}
+ { if (e.target === e.currentTarget) onClose() }}>
+ {navBtn('left', goPrev, hasPrev)}
+ {imgSrc &&  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/Layout/DemoBanner.tsx b/client/src/components/Layout/DemoBanner.tsx
index fff46be..1bbc53c 100644
--- a/client/src/components/Layout/DemoBanner.tsx
+++ b/client/src/components/Layout/DemoBanner.tsx
@@ -118,6 +118,70 @@ const texts: Record = {
selfHostLink: 'alójalo tú mismo',
close: 'Entendido',
},
+ zh: {
+ titleBefore: '欢迎来到 ',
+ titleAfter: '',
+ title: '欢迎来到 TREK 演示版',
+ description: '你可以查看、编辑和创建旅行。所有更改都会在每小时自动重置。',
+ resetIn: '下次重置将在',
+ minutes: '分钟后',
+ uploadNote: '演示模式下已禁用文件上传(照片、文档、封面)。',
+ fullVersionTitle: '完整版本还包括:',
+ features: [
+ '文件上传(照片、文档、封面)',
+ 'API 密钥管理(Google Maps、天气)',
+ '用户和权限管理',
+ '自动备份',
+ '附加组件管理(启用/禁用)',
+ 'OIDC / SSO 单点登录',
+ ],
+ addonsTitle: '模块化附加组件(完整版本可禁用)',
+ addons: [
+ ['Vacay', '带日历、节假日和用户融合的假期规划器'],
+ ['Atlas', '带已访问国家和旅行统计的世界地图'],
+ ['Packing', '按旅行管理清单'],
+ ['Budget', '支持分摊的费用追踪'],
+ ['Documents', '将文件附加到旅行'],
+ ['Widgets', '货币换算和时区工具'],
+ ],
+ whatIs: '什么是 TREK?',
+ whatIsDesc: '一个支持实时协作、交互式地图、OIDC 登录和深色模式的自托管旅行规划器。',
+ selfHost: '开源项目 - ',
+ selfHostLink: '自行部署',
+ close: '知道了',
+ },
+ 'zh-TW': {
+ titleBefore: '歡迎來到 ',
+ titleAfter: '',
+ title: '歡迎來到 TREK 展示版',
+ description: '你可以檢視、編輯和建立行程。所有變更都會在每小時自動重設。',
+ resetIn: '下次重設將在',
+ minutes: '分鐘後',
+ uploadNote: '展示模式下已停用檔案上傳(照片、文件、封面)。',
+ fullVersionTitle: '完整版本還包含:',
+ features: [
+ '檔案上傳(照片、文件、封面)',
+ 'API 金鑰管理(Google Maps、天氣)',
+ '使用者與權限管理',
+ '自動備份',
+ '附加元件管理(啟用/停用)',
+ 'OIDC / SSO 單一登入',
+ ],
+ addonsTitle: '模組化附加元件(完整版本可停用)',
+ addons: [
+ ['Vacay', '具備日曆、假日與使用者融合的假期規劃器'],
+ ['Atlas', '顯示已造訪國家與旅行統計的世界地圖'],
+ ['Packing', '依行程管理的檢查清單'],
+ ['Budget', '支援分攤的費用追蹤'],
+ ['Documents', '將檔案附加到行程'],
+ ['Widgets', '貨幣換算與時區工具'],
+ ],
+ whatIs: 'TREK 是什麼?',
+ whatIsDesc: '一個支援即時協作、互動式地圖、OIDC 登入和深色模式的自架旅行規劃器。',
+ selfHost: '開源專案 - ',
+ selfHostLink: '自行架設',
+ close: '知道了',
+ },
ar: {
titleBefore: 'مرحبًا بك في ',
titleAfter: '',
diff --git a/client/src/components/Layout/InAppNotificationBell.tsx b/client/src/components/Layout/InAppNotificationBell.tsx
index fcf14cb..0b22038 100644
--- a/client/src/components/Layout/InAppNotificationBell.tsx
+++ b/client/src/components/Layout/InAppNotificationBell.tsx
@@ -96,7 +96,7 @@ export default function InAppNotificationBell(): React.ReactElement {
{t('notifications.title')}
{unreadCount > 0 && (
+ style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{unreadCount}
)}
@@ -133,7 +133,7 @@ export default function InAppNotificationBell(): React.ReactElement {
{isLoading && notifications.length === 0 ? (
) : notifications.length === 0 ? (
@@ -154,7 +154,7 @@ export default function InAppNotificationBell(): React.ReactElement {
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0"
style={{
borderTop: '1px solid var(--border-secondary)',
- color: '#6366f1',
+ color: 'var(--text-primary)',
background: 'transparent',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx
index 1d92876..e4e1dc9 100644
--- a/client/src/components/Layout/Navbar.tsx
+++ b/client/src/components/Layout/Navbar.tsx
@@ -133,7 +133,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{tripTitle && (
<>
/
-
+
{tripTitle}
>
@@ -155,17 +155,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
)}
- {/* Dark mode toggle (light ↔ dark, overrides auto) */}
+ {/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
- {/* Notification bell */}
- {user && }
+ {/* Notification bell — only in trip view on mobile, everywhere on desktop */}
+ {user && tripId && }
+ {user && !tripId && }
{/* User menu */}
{user && (
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 13a8bab..a466e26 100644
--- a/client/src/components/Memories/MemoriesPanel.tsx
+++ b/client/src/components/Memories/MemoriesPanel.tsx
@@ -1,16 +1,23 @@
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 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 { getAuthUrl, fetchImageAsBlob, clearImageQueue } from '../../api/authUrl'
+import { fetchImageAsBlob, clearImageQueue } from '../../api/authUrl'
import { useToast } from '../shared/Toast'
-function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
+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(baseUrl).then(blobUrl => {
+ fetchImageAsBlob('/api' + baseUrl).then(blobUrl => {
revoke = blobUrl
setSrc(blobUrl)
})
@@ -19,18 +26,22 @@ function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React
return src ? : null
}
+
// ── Types ───────────────────────────────────────────────────────────────────
interface TripPhoto {
- immich_asset_id: string
+ asset_id: string
+ provider: string
user_id: number
username: string
shared: number
added_at: string
+ city?: string | null
}
-interface ImmichAsset {
+interface Asset {
id: string
+ provider: string
takenAt: string
city: string | null
country: string | null
@@ -50,6 +61,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
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)
@@ -57,7 +71,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
// Photo picker
const [showPicker, setShowPicker] = useState(false)
- const [pickerPhotos, setPickerPhotos] = useState([])
+ const [pickerPhotos, setPickerPhotos] = useState([])
const [pickerLoading, setPickerLoading] = useState(false)
const [selectedIds, setSelectedIds] = useState>(new Set())
@@ -72,49 +86,102 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
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 [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 `${ADDON_PREFIX}/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/${what}`
+ }
+
+ function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string {
+ const photo: TripPhoto = {
+ asset_id: asset.id,
+ provider: asset.provider,
+ user_id: userId,
+ username: '',
+ shared: 0,
+ added_at: null
+ }
+ return buildProviderAssetUrl(photo, what)
+ }
+
+
const loadAlbumLinks = async () => {
try {
- const res = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`)
+ const res = await apiClient.get(buildUnifiedUrl('album-links'))
setAlbumLinks(res.data.links || [])
} catch { setAlbumLinks([]) }
}
- const openAlbumPicker = async () => {
- setShowAlbumPicker(true)
+ const loadAlbums = async (provider: string = selectedProvider) => {
+ if (!provider) return
setAlbumsLoading(true)
try {
- const res = await apiClient.get('/integrations/immich/albums')
+ const res = await apiClient.get(buildProviderUrl(provider, 'albums'))
setAlbums(res.data.albums || [])
- } catch { setAlbums([]); toast.error(t('memories.error.loadAlbums')) }
- finally { setAlbumsLoading(false) }
+ } 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(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName })
+ 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(`/integrations/immich/trips/${tripId}/album-links`)
- const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId)
+ 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(`/integrations/immich/trips/${tripId}/album-links/${linkId}`)
- loadAlbumLinks()
+ await apiClient.delete(buildUnifiedUrl('album-links', linkId.toString()))
+ await loadAlbumLinks()
+ await loadPhotos()
} catch { toast.error(t('memories.error.unlinkAlbum')) }
}
- const syncAlbum = async (linkId: number) => {
+ const syncAlbum = async (linkId: number, provider?: string) => {
+ const targetProvider = provider || selectedProvider
+ if (!targetProvider) return
setSyncing(linkId)
try {
- await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`)
+ await apiClient.post(buildProviderUrl(targetProvider, 'album-link-sync', linkId.toString()))
await loadAlbumLinks()
await loadPhotos()
} catch { toast.error(t('memories.error.syncAlbum')) }
@@ -127,6 +194,14 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
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 ──────────────────────────────────────────────────────────────────
@@ -143,7 +218,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const loadPhotos = async () => {
try {
- const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`)
+ const photosRes = await apiClient.get(buildUnifiedUrl('photos'))
setTripPhotos(photosRes.data.photos || [])
} catch {
setTripPhotos([])
@@ -153,9 +228,37 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const loadInitial = async () => {
setLoading(true)
try {
- const statusRes = await apiClient.get('/integrations/immich/status')
- setConnected(statusRes.data.connected)
+ 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()
@@ -175,14 +278,35 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
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 res = await apiClient.post('/integrations/immich/search', {
+ 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 || [])
+ setPickerPhotos((res.data.assets || []).map((asset: Asset) => ({ ...asset, provider: provider.id })))
} catch {
setPickerPhotos([])
toast.error(t('memories.error.loadPhotos'))
@@ -208,8 +332,17 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const executeAddPhotos = async () => {
setShowConfirmShare(false)
try {
- await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, {
- asset_ids: [...selectedIds],
+ 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)
@@ -220,28 +353,38 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
// ── Remove photo ──────────────────────────────────────────────────────────
- const removePhoto = async (assetId: string) => {
+ const removePhoto = async (photo: TripPhoto) => {
try {
- await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
- setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
+ await apiClient.delete(buildUnifiedUrl('photos'), {
+ data: {
+ asset_id: photo.asset_id,
+ provider: photo.provider,
+ },
+ })
+ setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id)))
} catch { toast.error(t('memories.error.removePhoto')) }
}
// ── Toggle sharing ────────────────────────────────────────────────────────
- const toggleSharing = async (assetId: string, shared: boolean) => {
+ const toggleSharing = async (photo: TripPhoto, shared: boolean) => {
try {
- await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared })
+ await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
+ shared,
+ asset_id: photo.asset_id,
+ provider: photo.provider,
+ })
setTripPhotos(prev => prev.map(p =>
- p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
+ p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...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 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)
@@ -281,10 +424,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
- {t('memories.notConnected')}
+ {t('memories.notConnected', { provider_name: enabledProviders.length === 1 ? enabledProviders[0]?.name : 'Photo provider' })}
- {t('memories.notConnectedHint')}
+ {enabledProviders.length === 1 ? t('memories.notConnectedHint', { provider_name: enabledProviders[0]?.name }) : t('memories.notConnectedMultipleHint', { provider_names: enabledProviders.map(p => p.name).join(', ') })}
)
@@ -292,22 +435,53 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
// ── 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.immich_album_id))
+ const linkedIds = new Set(albumLinks.map(l => l.album_id))
return (
- {t('memories.selectAlbum')}
+ {availableProviders.length > 1 ? t('memories.selectAlbumMultiple') : t('memories.selectAlbum', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}
+
{albumsLoading ? (
@@ -359,7 +533,11 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
// ── Photo Picker Modal ────────────────────────────────────────────────────
if (showPicker) {
- const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id))
+ const alreadyAdded = new Set(
+ tripPhotos
+ .filter(p => p.user_id === currentUser?.id)
+ .map(p => makePickerKey(p.provider, p.asset_id))
+ )
return (
<>
@@ -368,7 +546,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
- {t('memories.selectPhotos')}
+ {availableProviders.length > 1 ? t('memories.selectPhotosMultiple') : t('memories.selectPhotos', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}
+
{/* Filter tabs */}
{startDate && endDate && (
@@ -429,10 +610,17 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{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 = {}
+ 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'
@@ -450,11 +638,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{byMonth[month].map(asset => {
- const isSelected = selectedIds.has(asset.id)
- const isAlready = alreadyAdded.has(asset.id)
+ const pickerKey = makePickerKey(asset.provider, asset.id)
+ const isSelected = selectedIds.has(pickerKey)
+ const isAlready = alreadyAdded.has(pickerKey)
return (
- !isAlready && togglePickerSelect(asset.id)}
+ !isAlready && togglePickerSelect(pickerKey)}
style={{
position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
cursor: isAlready ? 'default' : 'pointer',
@@ -462,7 +651,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
outlineOffset: -3,
}}>
-
{isSelected && (
{link.album_name}
{link.username !== currentUser?.username && ({link.username})}
- |