mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user