diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 74a72a0..2bc4f7d 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 } from 'lucide-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' @@ -52,6 +52,59 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa 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([]) } + 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 {} + } + + const unlinkAlbum = async (linkId: number) => { + try { + await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`) + loadAlbumLinks() + } catch {} + } + + const syncAlbum = async (linkId: number) => { + setSyncing(linkId) + try { + await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`) + await loadAlbumLinks() + await loadPhotos() + } catch {} + finally { setSyncing(null) } + } + // Lightbox const [lightboxId, setLightboxId] = useState(null) const [lightboxUserId, setLightboxUserId] = useState(null) @@ -89,6 +142,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setConnected(false) } await loadPhotos() + await loadAlbumLinks() setLoading(false) } @@ -224,6 +278,72 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── 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)) @@ -404,16 +524,52 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa

{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 */} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 1b08bf8..e0587a9 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1350,6 +1350,12 @@ const ar: Record = { 'memories.newest': 'الأحدث أولاً', 'memories.allLocations': 'جميع المواقع', 'memories.addPhotos': 'إضافة صور', + 'memories.linkAlbum': 'ربط ألبوم', + 'memories.selectAlbum': 'اختيار ألبوم Immich', + 'memories.noAlbums': 'لم يتم العثور على ألبومات', + 'memories.syncAlbum': 'مزامنة الألبوم', + 'memories.unlinkAlbum': 'إلغاء الربط', + 'memories.photos': 'صور', 'memories.selectPhotos': 'اختيار صور من Immich', 'memories.selectHint': 'انقر على الصور لتحديدها.', 'memories.selected': 'محدد', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 054ae5f..9ea1d24 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1398,6 +1398,12 @@ const br: Record = { 'memories.connectionError': 'Não foi possível conectar ao Immich', 'memories.saved': 'Configurações do Immich salvas', 'memories.addPhotos': 'Adicionar fotos', + 'memories.linkAlbum': 'Vincular álbum', + 'memories.selectAlbum': 'Selecionar álbum do Immich', + 'memories.noAlbums': 'Nenhum álbum encontrado', + 'memories.syncAlbum': 'Sincronizar álbum', + 'memories.unlinkAlbum': 'Desvincular', + 'memories.photos': 'fotos', 'memories.selectPhotos': 'Selecionar fotos do Immich', 'memories.selectHint': 'Toque nas fotos para selecioná-las.', 'memories.selected': 'selecionadas', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 26f0c33..09c625b 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1347,6 +1347,12 @@ const cs: Record = { 'memories.connectionError': 'Nepodařilo se připojit k Immich', 'memories.saved': 'Nastavení Immich uloženo', 'memories.addPhotos': 'Přidat fotky', + 'memories.linkAlbum': 'Propojit album', + 'memories.selectAlbum': 'Vybrat album z Immich', + 'memories.noAlbums': 'Žádná alba nenalezena', + 'memories.syncAlbum': 'Synchronizovat album', + 'memories.unlinkAlbum': 'Odpojit', + 'memories.photos': 'fotek', 'memories.selectPhotos': 'Vybrat fotky z Immich', 'memories.selectHint': 'Klepněte na fotky pro jejich výběr.', 'memories.selected': 'vybráno', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 0c32841..f7ba5da 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1344,6 +1344,12 @@ const de: Record = { 'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen', 'memories.saved': 'Immich-Einstellungen gespeichert', 'memories.addPhotos': 'Fotos hinzufügen', + 'memories.linkAlbum': 'Album verknüpfen', + 'memories.selectAlbum': 'Immich-Album auswählen', + 'memories.noAlbums': 'Keine Alben gefunden', + 'memories.syncAlbum': 'Album synchronisieren', + 'memories.unlinkAlbum': 'Album trennen', + 'memories.photos': 'Fotos', 'memories.selectPhotos': 'Fotos aus Immich auswählen', 'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.', 'memories.selected': 'ausgewählt', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index d20130d..3a6cef8 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1341,6 +1341,12 @@ const en: Record = { 'memories.connectionError': 'Could not connect to Immich', 'memories.saved': 'Immich settings saved', 'memories.addPhotos': 'Add photos', + 'memories.linkAlbum': 'Link Album', + 'memories.selectAlbum': 'Select Immich Album', + 'memories.noAlbums': 'No albums found', + 'memories.syncAlbum': 'Sync album', + 'memories.unlinkAlbum': 'Unlink album', + 'memories.photos': 'photos', 'memories.selectPhotos': 'Select photos from Immich', 'memories.selectHint': 'Tap photos to select them.', 'memories.selected': 'selected', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 9c0dedc..82d2bd6 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1300,6 +1300,12 @@ const es: Record = { 'memories.newest': 'Más recientes', 'memories.allLocations': 'Todas las ubicaciones', 'memories.addPhotos': 'Añadir fotos', + 'memories.linkAlbum': 'Vincular álbum', + 'memories.selectAlbum': 'Seleccionar álbum de Immich', + 'memories.noAlbums': 'No se encontraron álbumes', + 'memories.syncAlbum': 'Sincronizar álbum', + 'memories.unlinkAlbum': 'Desvincular', + 'memories.photos': 'fotos', 'memories.selectPhotos': 'Seleccionar fotos de Immich', 'memories.selectHint': 'Toca las fotos para seleccionarlas.', 'memories.selected': 'seleccionado(s)', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index d0caa3a..2abd843 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1346,6 +1346,12 @@ const fr: Record = { 'memories.newest': 'Plus récentes', 'memories.allLocations': 'Tous les lieux', 'memories.addPhotos': 'Ajouter des photos', + 'memories.linkAlbum': 'Lier un album', + 'memories.selectAlbum': 'Choisir un album Immich', + 'memories.noAlbums': 'Aucun album trouvé', + 'memories.syncAlbum': 'Synchroniser', + 'memories.unlinkAlbum': 'Délier', + 'memories.photos': 'photos', 'memories.selectPhotos': 'Sélectionner des photos depuis Immich', 'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.', 'memories.selected': 'sélectionné(s)', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 34589ce..9f0c608 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1414,6 +1414,12 @@ const hu: Record = { 'memories.connectionError': 'Nem sikerült csatlakozni az Immichhez', 'memories.saved': 'Immich beállítások mentve', 'memories.addPhotos': 'Fotók hozzáadása', + 'memories.linkAlbum': 'Album csatolása', + 'memories.selectAlbum': 'Immich album kiválasztása', + 'memories.noAlbums': 'Nem található album', + 'memories.syncAlbum': 'Album szinkronizálása', + 'memories.unlinkAlbum': 'Leválasztás', + 'memories.photos': 'fotó', 'memories.selectPhotos': 'Fotók kiválasztása az Immichből', 'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.', 'memories.selected': 'kijelölve', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 377a2dd..5c3ee54 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1344,6 +1344,12 @@ const it: Record = { 'memories.connectionError': 'Impossibile connettersi a Immich', 'memories.saved': 'Impostazioni Immich salvate', 'memories.addPhotos': 'Aggiungi foto', + 'memories.linkAlbum': 'Collega album', + 'memories.selectAlbum': 'Seleziona album Immich', + 'memories.noAlbums': 'Nessun album trovato', + 'memories.syncAlbum': 'Sincronizza album', + 'memories.unlinkAlbum': 'Scollega', + 'memories.photos': 'foto', 'memories.selectPhotos': 'Seleziona foto da Immich', 'memories.selectHint': 'Tocca le foto per selezionarle.', 'memories.selected': 'selezionate', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index eb22e5b..b47f198 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1346,6 +1346,12 @@ const nl: Record = { 'memories.newest': 'Nieuwste eerst', 'memories.allLocations': 'Alle locaties', 'memories.addPhotos': 'Foto\'s toevoegen', + 'memories.linkAlbum': 'Album koppelen', + 'memories.selectAlbum': 'Immich-album selecteren', + 'memories.noAlbums': 'Geen albums gevonden', + 'memories.syncAlbum': 'Album synchroniseren', + 'memories.unlinkAlbum': 'Ontkoppelen', + 'memories.photos': 'fotos', 'memories.selectPhotos': 'Selecteer foto\'s uit Immich', 'memories.selectHint': 'Tik op foto\'s om ze te selecteren.', 'memories.selected': 'geselecteerd', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 03c843a..9f74102 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1346,6 +1346,12 @@ const ru: Record = { 'memories.newest': 'Сначала новые', 'memories.allLocations': 'Все места', 'memories.addPhotos': 'Добавить фото', + 'memories.linkAlbum': 'Привязать альбом', + 'memories.selectAlbum': 'Выбрать альбом Immich', + 'memories.noAlbums': 'Альбомы не найдены', + 'memories.syncAlbum': 'Синхронизировать', + 'memories.unlinkAlbum': 'Отвязать', + 'memories.photos': 'фото', 'memories.selectPhotos': 'Выбрать фото из Immich', 'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.', 'memories.selected': 'выбрано', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 156dec5..b376baf 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1346,6 +1346,12 @@ const zh: Record = { 'memories.newest': '最新优先', 'memories.allLocations': '所有地点', 'memories.addPhotos': '添加照片', + 'memories.linkAlbum': '关联相册', + 'memories.selectAlbum': '选择 Immich 相册', + 'memories.noAlbums': '未找到相册', + 'memories.syncAlbum': '同步相册', + 'memories.unlinkAlbum': '取消关联', + 'memories.photos': '张照片', 'memories.selectPhotos': '从 Immich 选择照片', 'memories.selectHint': '点击照片以选择。', 'memories.selected': '已选择', diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index e6f2929..9a0106a 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -439,6 +439,22 @@ function runMigrations(db: Database.Database): void { () => { try { db.exec('ALTER TABLE budget_items ADD COLUMN expense_date TEXT DEFAULT NULL'); } catch {} }, + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS trip_album_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + immich_album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, immich_album_id) + ); + CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index ef3891b..8421df1 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -268,8 +268,9 @@ router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - // Only allow accessing own Immich credentials — prevent leaking other users' API keys - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + // Use photo owner's Immich credentials if userId is provided (for shared photos) + const targetUserId = req.query.userId ? Number(req.query.userId) : authReq.user.id; + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any; if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found'); try { @@ -292,8 +293,9 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - // Only allow accessing own Immich credentials — prevent leaking other users' API keys - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + // Use photo owner's Immich credentials if userId is provided (for shared photos) + const targetUserId = req.query.userId ? Number(req.query.userId) : authReq.user.id; + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any; if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found'); try { @@ -311,4 +313,110 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: } }); +// ── Album Linking ────────────────────────────────────────────────────────── + +// List user's Immich albums +router.get('/albums', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); + + try { + const resp = await fetch(`${user.immich_url}/api/albums`, { + headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch albums' }); + const albums = (await resp.json() as any[]).map((a: any) => ({ + id: a.id, + albumName: a.albumName, + assetCount: a.assetCount || 0, + startDate: a.startDate, + endDate: a.endDate, + albumThumbnailAssetId: a.albumThumbnailAssetId, + })); + res.json({ albums }); + } catch { + res.status(502).json({ error: 'Could not reach Immich' }); + } +}); + +// Get album links for a trip +router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { + const links = db.prepare(` + SELECT tal.*, u.username + FROM trip_album_links tal + JOIN users u ON tal.user_id = u.id + WHERE tal.trip_id = ? + ORDER BY tal.created_at ASC + `).all(req.params.tripId); + res.json({ links }); +}); + +// Link an album to a trip +router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const { album_id, album_name } = req.body; + if (!album_id) return res.status(400).json({ error: 'album_id required' }); + + try { + db.prepare( + 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, immich_album_id, album_name) VALUES (?, ?, ?, ?)' + ).run(tripId, authReq.user.id, album_id, album_name || ''); + res.json({ success: true }); + } catch (err: any) { + res.status(400).json({ error: 'Album already linked' }); + } +}); + +// Remove album link +router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .run(req.params.linkId, req.params.tripId, authReq.user.id); + res.json({ success: true }); +}); + +// Sync album — fetch all assets from Immich album and add missing ones to trip +router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, linkId } = req.params; + + const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .get(linkId, tripId, authReq.user.id) as any; + if (!link) return res.status(404).json({ error: 'Album link not found' }); + + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); + + try { + const resp = await fetch(`${user.immich_url}/api/albums/${link.immich_album_id}`, { + headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(15000), + }); + if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch album' }); + const albumData = await resp.json() as { assets?: any[] }; + const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE'); + + const insert = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, 1)' + ); + let added = 0; + for (const asset of assets) { + const r = insert.run(tripId, authReq.user.id, asset.id); + if (r.changes > 0) added++; + } + + db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + + res.json({ success: true, added, total: assets.length }); + if (added > 0) { + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); + } + } catch { + res.status(502).json({ error: 'Could not reach Immich' }); + } +}); + export default router;