feat: unified photo provider abstraction layer (#584)

Introduce trek_photos as central photo registry. Frontend uses
/api/photos/:id/:kind instead of provider-specific URLs. Adding
a new photo provider is now backend-only work.

- New trek_photos table (migration 98) with photo_id FK in
  trip_photos and journey_photos
- Unified /api/photos/:id/thumbnail|original|info endpoint
- photoResolverService for central resolution and streaming
- ProviderPicker: add "All Photos" tab, rename tabs, fix i18n
- Localize all hardcoded strings in JourneyDetailPage (14 langs)
- Fix date formatting to use browser locale instead of hardcoded 'en'
- Journey stats as styled tile cards
This commit is contained in:
Maurice
2026-04-13 20:08:31 +02:00
parent e629548a42
commit c0c59b6d80
34 changed files with 883 additions and 198 deletions
@@ -233,8 +233,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
{ asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
{ photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
{ photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
],
})
),
@@ -501,8 +501,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
@@ -676,8 +676,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
@@ -30,6 +30,7 @@ function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; p
// ── Types ───────────────────────────────────────────────────────────────────
interface TripPhoto {
photo_id: number
asset_id: string
provider: string
user_id: number
@@ -105,19 +106,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
}
function buildProviderAssetUrl(photo: TripPhoto, what: string): string {
return `${ADDON_PREFIX}/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/${what}`
return `/photos/${photo.photo_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)
// Picker photos are not yet saved — use provider-specific URL
return `${ADDON_PREFIX}/${asset.provider}/assets/${tripId}/${asset.id}/${userId}/${what}`
}
@@ -189,7 +183,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
}
// Lightbox
const [lightboxId, setLightboxId] = useState<string | null>(null)
const [lightboxId, setLightboxId] = useState<number | null>(null)
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
@@ -357,11 +351,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try {
await apiClient.delete(buildUnifiedUrl('photos'), {
data: {
asset_id: photo.asset_id,
provider: photo.provider,
photo_id: photo.photo_id,
},
})
setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id)))
setTripPhotos(prev => prev.filter(p => p.photo_id !== photo.photo_id))
} catch { toast.error(t('memories.error.removePhoto')) }
}
@@ -371,11 +364,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try {
await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
shared,
asset_id: photo.asset_id,
provider: photo.provider,
photo_id: photo.photo_id,
})
setTripPhotos(prev => prev.map(p =>
p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p
p.photo_id === photo.photo_id ? { ...p, shared: shared ? 1 : 0 } : p
))
} catch { toast.error(t('memories.error.toggleSharing')) }
}
@@ -839,10 +831,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{allVisible.map(photo => {
const isOwn = photo.user_id === currentUser?.id
return (
<div key={`${photo.provider}:${photo.asset_id}`} className="group"
<div key={photo.photo_id} className="group"
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
onClick={() => {
setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
setLightboxId(photo.photo_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
@@ -961,7 +953,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setShowMobileInfo(false)
}
const currentIdx = allVisible.findIndex(p => p.asset_id === lightboxId)
const currentIdx = allVisible.findIndex(p => p.photo_id === lightboxId)
const hasPrev = currentIdx > 0
const hasNext = currentIdx < allVisible.length - 1
const navigateTo = (idx: number) => {
@@ -969,7 +961,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
if (!photo) return
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
setLightboxId(photo.asset_id)
setLightboxId(photo.photo_id)
setLightboxUserId(photo.user_id)
setLightboxInfo(null)
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
+1 -2
View File
@@ -19,8 +19,7 @@ function abs(url: string | null | undefined): string {
}
function pSrc(p: JourneyPhoto): string {
if (p.provider === 'local') return abs(`/uploads/${p.file_path}`)
return abs(`/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/original`)
return abs(`/api/photos/${p.photo_id}/original`)
}
function fmtDate(d: string): string {
@@ -286,7 +286,7 @@ export default function PlaceFormModal({
onChange={e => handleChange('description', e.target.value)}
rows={2}
placeholder={t('places.formDescriptionPlaceholder')}
className="form-input" style={{ resize: 'none' }}
className="form-input" style={{ resize: 'vertical' }}
/>
</div>