feat: Immich album linking with auto-sync (#206)
- Link Immich albums to trips — photos sync automatically - Album picker shows all user's Immich albums - Linked albums displayed as chips with sync/unlink buttons - Auto-sync on link: fetches all album photos and adds to trip - Manual re-sync button for each linked album - DB migration: trip_album_links table fix: shared Immich photos visible to other trip members - Thumbnail/original proxy now uses photo owner's Immich credentials when userId query param is provided, fixing 404 for shared photos - i18n: album keys for all 12 languages
This commit is contained in:
@@ -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<number | null>(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<string | null>(null)
|
||||
const [lightboxUserId, setLightboxUserId] = useState<number | null>(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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('memories.selectAlbum')}
|
||||
</h3>
|
||||
<button onClick={() => setShowAlbumPicker(false)}
|
||||
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||
{albumsLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<div style={{ width: 24, height: 24, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
|
||||
</div>
|
||||
) : albums.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', padding: 40, fontSize: 13, color: 'var(--text-faint)' }}>
|
||||
{t('memories.noAlbums')}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{albums.map(album => {
|
||||
const isLinked = linkedIds.has(album.id)
|
||||
return (
|
||||
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName)}
|
||||
disabled={isLinked}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px',
|
||||
borderRadius: 10, border: 'none', cursor: isLinked ? 'default' : 'pointer',
|
||||
background: isLinked ? 'var(--bg-tertiary)' : 'transparent', fontFamily: 'inherit', textAlign: 'left',
|
||||
opacity: isLinked ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={e => { if (!isLinked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isLinked) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<FolderOpen size={20} color="var(--text-muted)" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{album.albumName}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
|
||||
{album.assetCount} {t('memories.photos')}
|
||||
</div>
|
||||
</div>
|
||||
{isLinked ? (
|
||||
<Check size={16} color="var(--text-faint)" />
|
||||
) : (
|
||||
<Link2 size={16} color="var(--text-muted)" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
</p>
|
||||
</div>
|
||||
{connected && (
|
||||
<button onClick={openPicker}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={14} /> {t('memories.addPhotos')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button onClick={openAlbumPicker}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)',
|
||||
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={13} /> {t('memories.linkAlbum')}
|
||||
</button>
|
||||
<button onClick={openPicker}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={14} /> {t('memories.addPhotos')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Linked Albums */}
|
||||
{albumLinks.length > 0 && (
|
||||
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border-secondary)', display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{albumLinks.map(link => (
|
||||
<div key={link.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 8,
|
||||
background: 'var(--bg-tertiary)', fontSize: 11, color: 'var(--text-muted)',
|
||||
}}>
|
||||
<FolderOpen size={11} />
|
||||
<span style={{ fontWeight: 500 }}>{link.album_name}</span>
|
||||
{link.username !== currentUser?.username && <span style={{ color: 'var(--text-faint)' }}>({link.username})</span>}
|
||||
<button onClick={() => syncAlbum(link.id)} disabled={syncing === link.id} title={t('memories.syncAlbum')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
|
||||
<RefreshCw size={11} style={{ animation: syncing === link.id ? 'spin 1s linear infinite' : 'none' }} />
|
||||
</button>
|
||||
{link.user_id === currentUser?.id && (
|
||||
<button onClick={() => unlinkAlbum(link.id)} title={t('memories.unlinkAlbum')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter & Sort bar */}
|
||||
|
||||
@@ -1350,6 +1350,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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': 'محدد',
|
||||
|
||||
@@ -1398,6 +1398,12 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1347,6 +1347,12 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1344,6 +1344,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1341,6 +1341,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1300,6 +1300,12 @@ const es: Record<string, string> = {
|
||||
'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)',
|
||||
|
||||
@@ -1346,6 +1346,12 @@ const fr: Record<string, string> = {
|
||||
'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)',
|
||||
|
||||
@@ -1414,6 +1414,12 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1344,6 +1344,12 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1346,6 +1346,12 @@ const nl: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -1346,6 +1346,12 @@ const ru: Record<string, string> = {
|
||||
'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': 'выбрано',
|
||||
|
||||
@@ -1346,6 +1346,12 @@ const zh: Record<string, string> = {
|
||||
'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': '已选择',
|
||||
|
||||
Reference in New Issue
Block a user