From c0c59b6d801b4c53b0e696254fbc9604090dee21 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 13 Apr 2026 20:08:31 +0200 Subject: [PATCH] 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 --- .../Memories/MemoriesPanel.test.tsx | 12 +- .../src/components/Memories/MemoriesPanel.tsx | 34 ++--- client/src/components/PDF/JourneyBookPDF.tsx | 3 +- .../src/components/Planner/PlaceFormModal.tsx | 2 +- client/src/i18n/translations/ar.ts | 34 +++++ client/src/i18n/translations/br.ts | 21 +++ client/src/i18n/translations/cs.ts | 21 +++ client/src/i18n/translations/de.ts | 21 +++ client/src/i18n/translations/en.ts | 21 +++ client/src/i18n/translations/es.ts | 21 +++ client/src/i18n/translations/fr.ts | 21 +++ client/src/i18n/translations/hu.ts | 21 +++ client/src/i18n/translations/it.ts | 21 +++ client/src/i18n/translations/nl.ts | 21 +++ client/src/i18n/translations/pl.ts | 21 +++ client/src/i18n/translations/ru.ts | 21 +++ client/src/i18n/translations/zh.ts | 23 ++- client/src/i18n/translations/zhTw.ts | 23 ++- client/src/pages/JourneyDetailPage.test.tsx | 33 ++-- client/src/pages/JourneyDetailPage.tsx | 137 ++++++++--------- client/src/pages/JourneyPublicPage.test.tsx | 8 +- client/src/pages/JourneyPublicPage.tsx | 6 +- client/src/store/journeyStore.ts | 12 +- server/src/app.ts | 2 + server/src/db/migrations.ts | 109 ++++++++++++++ server/src/routes/journeyPublic.ts | 18 ++- server/src/routes/memories/unified.ts | 5 +- server/src/routes/photos.ts | 47 ++++++ server/src/services/journeyService.ts | 65 ++++---- server/src/services/journeyShareService.ts | 16 +- .../src/services/memories/helpersService.ts | 69 +++++++-- .../services/memories/photoResolverService.ts | 141 ++++++++++++++++++ .../src/services/memories/unifiedService.ts | 27 ++-- server/src/types.ts | 24 ++- 34 files changed, 883 insertions(+), 198 deletions(-) create mode 100644 server/src/routes/photos.ts create mode 100644 server/src/services/memories/photoResolverService.ts diff --git a/client/src/components/Memories/MemoriesPanel.test.tsx b/client/src/components/Memories/MemoriesPanel.test.tsx index f25a3dce..cbb914a2 100644 --- a/client/src/components/Memories/MemoriesPanel.test.tsx +++ b/client/src/components/Memories/MemoriesPanel.test.tsx @@ -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' }, ], }) ), diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index ec14ab25..72c79f18 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -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(null) + const [lightboxId, setLightboxId] = useState(null) const [lightboxUserId, setLightboxUserId] = useState(null) const [lightboxInfo, setLightboxInfo] = useState(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 ( -
{ - 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) diff --git a/client/src/components/PDF/JourneyBookPDF.tsx b/client/src/components/PDF/JourneyBookPDF.tsx index dfd07348..80d38333 100644 --- a/client/src/components/PDF/JourneyBookPDF.tsx +++ b/client/src/components/PDF/JourneyBookPDF.tsx @@ -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 { diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 0566b3ae..b366c6a0 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -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' }} />
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index cff3eb41..31ae8002 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1539,6 +1539,40 @@ const ar: Record = { 'journey.entries.deleteTitle': 'حذف الإدخال', 'journey.photosUploaded': 'تم رفع {count} صورة', 'journey.photosAdded': 'تمت إضافة {count} صورة', + 'journey.picker.tripPeriod': 'فترة الرحلة', + 'journey.picker.dateRange': 'نطاق التاريخ', + 'journey.picker.allPhotos': 'كل الصور', + 'journey.picker.albums': 'ألبومات', + 'journey.picker.selected': 'محدد', + 'journey.picker.addTo': 'إضافة إلى', + 'journey.picker.newGallery': 'معرض جديد', + 'journey.picker.noAlbums': 'لم يتم العثور على ألبومات', + 'journey.picker.selectDate': 'اختر تاريخ', + 'journey.picker.search': 'بحث', + + // Journey Detail + 'journey.detail.photos': 'صور', + 'journey.detail.backToJourney': 'العودة للمجلة', + 'journey.detail.day': 'اليوم {number}', + 'journey.detail.places': 'أماكن', + + // Journey — Invite + 'journey.invite.role': 'الدور', + 'journey.invite.viewer': 'مشاهد', + 'journey.invite.editor': 'محرر', + 'journey.invite.invite': 'دعوة', + 'journey.invite.inviting': 'جارٍ الدعوة...', + + // Journey Entry Editor + 'journey.editor.uploadPhotos': 'رفع صور', + 'journey.editor.fromGallery': 'من المعرض', + 'journey.editor.addAnother': 'إضافة آخر', + 'journey.editor.makeFirst': 'جعله الأول', + 'journey.editor.searching': 'جارٍ البحث...', + + // Journey — Share + 'journey.share.copy': 'نسخ', + 'journey.share.copied': 'تم النسخ!', // Collab Addon 'collab.tabs.chat': 'الدردشة', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 437096d2..dd67826b 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1902,6 +1902,9 @@ const br: Record = { 'journey.detail.contributors': 'Colaboradores', 'journey.detail.readMore': 'Ler mais', 'journey.detail.prosCons': 'Prós e contras', + 'journey.detail.photos': 'fotos', + 'journey.detail.day': 'Dia {number}', + 'journey.detail.places': 'lugares', 'journey.stats.days': 'Dias', 'journey.stats.cities': 'Cidades', 'journey.stats.entries': 'Entradas', @@ -1928,6 +1931,7 @@ const br: Record = { 'journey.editor.weather': 'Clima', 'journey.editor.photoFirst': '1º', 'journey.editor.makeFirst': 'Tornar 1º', + 'journey.editor.searching': 'Pesquisando...', 'journey.mood.amazing': 'Incrível', 'journey.mood.good': 'Bom', 'journey.mood.neutral': 'Neutro', @@ -1972,6 +1976,13 @@ const br: Record = { 'journey.share.linkDeleted': 'Link de compartilhamento removido', 'journey.share.deleteFailed': 'Não foi possível excluir', 'journey.share.updateFailed': 'Não foi possível atualizar', + + // Journey — Invite + 'journey.invite.role': 'Função', + 'journey.invite.viewer': 'Visualizador', + 'journey.invite.editor': 'Editor', + 'journey.invite.invite': 'Convidar', + 'journey.invite.inviting': 'Convidando...', 'journey.settings.title': 'Configurações da jornada', 'journey.settings.coverImage': 'Imagem de capa', 'journey.settings.changeCover': 'Alterar capa', @@ -2002,6 +2013,16 @@ const br: Record = { 'journey.pdf.theEnd': 'Fim', 'journey.pdf.saveAsPdf': 'Salvar como PDF', 'journey.pdf.pages': 'páginas', + 'journey.picker.tripPeriod': 'Período da viagem', + 'journey.picker.dateRange': 'Período', + 'journey.picker.allPhotos': 'Todas as fotos', + 'journey.picker.albums': 'Álbuns', + 'journey.picker.selected': 'selecionados', + 'journey.picker.addTo': 'Adicionar a', + 'journey.picker.newGallery': 'Nova galeria', + 'journey.picker.noAlbums': 'Nenhum álbum encontrado', + 'journey.picker.selectDate': 'Selecionar data', + 'journey.picker.search': 'Pesquisar', 'dashboard.greeting.morning': 'Bom dia,', 'dashboard.greeting.afternoon': 'Boa tarde,', 'dashboard.greeting.evening': 'Boa noite,', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 87b35787..691d945d 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1904,6 +1904,9 @@ const cs: Record = { 'journey.detail.contributors': 'Přispěvatelé', 'journey.detail.readMore': 'Číst dále', 'journey.detail.prosCons': 'Klady a zápory', + 'journey.detail.photos': 'fotky', + 'journey.detail.day': 'Den {number}', + 'journey.detail.places': 'míst', 'journey.stats.days': 'Dny', 'journey.stats.cities': 'Města', 'journey.stats.entries': 'Záznamy', @@ -1930,6 +1933,7 @@ const cs: Record = { 'journey.editor.weather': 'Počasí', 'journey.editor.photoFirst': '1.', 'journey.editor.makeFirst': 'Nastavit jako 1.', + 'journey.editor.searching': 'Hledání...', 'journey.mood.amazing': 'Úžasný', 'journey.mood.good': 'Dobrý', 'journey.mood.neutral': 'Neutrální', @@ -1974,6 +1978,13 @@ const cs: Record = { 'journey.share.linkDeleted': 'Odkaz ke sdílení smazán', 'journey.share.deleteFailed': 'Smazání selhalo', 'journey.share.updateFailed': 'Aktualizace selhala', + + // Journey — Invite + 'journey.invite.role': 'Role', + 'journey.invite.viewer': 'Čtenář', + 'journey.invite.editor': 'Editor', + 'journey.invite.invite': 'Pozvat', + 'journey.invite.inviting': 'Zveme...', 'journey.settings.title': 'Nastavení cestovního deníku', 'journey.settings.coverImage': 'Titulní obrázek', 'journey.settings.changeCover': 'Změnit obal', @@ -2004,6 +2015,16 @@ const cs: Record = { 'journey.pdf.theEnd': 'Konec', 'journey.pdf.saveAsPdf': 'Uložit jako PDF', 'journey.pdf.pages': 'stran', + 'journey.picker.tripPeriod': 'Období cesty', + 'journey.picker.dateRange': 'Časové období', + 'journey.picker.allPhotos': 'Všechny fotky', + 'journey.picker.albums': 'Alba', + 'journey.picker.selected': 'vybráno', + 'journey.picker.addTo': 'Přidat do', + 'journey.picker.newGallery': 'Nová galerie', + 'journey.picker.noAlbums': 'Žádná alba nenalezena', + 'journey.picker.selectDate': 'Vyberte datum', + 'journey.picker.search': 'Hledat', 'dashboard.greeting.morning': 'Dobré ráno,', 'dashboard.greeting.afternoon': 'Dobré odpoledne,', 'dashboard.greeting.evening': 'Dobrý večer,', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index f2aaffb6..48704326 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1892,6 +1892,9 @@ const de: Record = { 'journey.detail.contributors': 'Mitwirkende', 'journey.detail.readMore': 'Mehr lesen', 'journey.detail.prosCons': 'Pro & Contra', + 'journey.detail.photos': 'Fotos', + 'journey.detail.day': 'Tag {number}', + 'journey.detail.places': 'Orte', 'journey.stats.days': 'Tage', 'journey.stats.cities': 'Städte', 'journey.stats.entries': 'Einträge', @@ -1918,6 +1921,7 @@ const de: Record = { 'journey.editor.weather': 'Wetter', 'journey.editor.photoFirst': '1.', 'journey.editor.makeFirst': 'Als 1. setzen', + 'journey.editor.searching': 'Suche...', 'journey.mood.amazing': 'Großartig', 'journey.mood.good': 'Gut', 'journey.mood.neutral': 'Neutral', @@ -1962,6 +1966,13 @@ const de: Record = { 'journey.share.linkDeleted': 'Link entfernt', 'journey.share.deleteFailed': 'Entfernen fehlgeschlagen', 'journey.share.updateFailed': 'Aktualisierung fehlgeschlagen', + + // Journey — Invite + 'journey.invite.role': 'Rolle', + 'journey.invite.viewer': 'Betrachter', + 'journey.invite.editor': 'Bearbeiter', + 'journey.invite.invite': 'Einladen', + 'journey.invite.inviting': 'Wird eingeladen...', 'journey.settings.title': 'Journey-Einstellungen', 'journey.settings.coverImage': 'Titelbild', 'journey.settings.changeCover': 'Titelbild ändern', @@ -1992,6 +2003,16 @@ const de: Record = { 'journey.pdf.theEnd': 'Ende', 'journey.pdf.saveAsPdf': 'Als PDF speichern', 'journey.pdf.pages': 'Seiten', + 'journey.picker.tripPeriod': 'Reisezeitraum', + 'journey.picker.dateRange': 'Zeitraum', + 'journey.picker.allPhotos': 'Alle Fotos', + 'journey.picker.albums': 'Alben', + 'journey.picker.selected': 'ausgewählt', + 'journey.picker.addTo': 'Hinzufügen zu', + 'journey.picker.newGallery': 'Neue Galerie', + 'journey.picker.noAlbums': 'Keine Alben gefunden', + 'journey.picker.selectDate': 'Datum wählen', + 'journey.picker.search': 'Suchen', 'dashboard.greeting.morning': 'Guten Morgen,', 'dashboard.greeting.afternoon': 'Guten Tag,', 'dashboard.greeting.evening': 'Guten Abend,', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 13593e11..472b9ce3 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1895,6 +1895,9 @@ const en: Record = { 'journey.detail.contributors': 'Contributors', 'journey.detail.readMore': 'Read more', 'journey.detail.prosCons': 'Pros & Cons', + 'journey.detail.photos': 'photos', + 'journey.detail.day': 'Day {number}', + 'journey.detail.places': 'places', // Journey Detail — Stats 'journey.stats.days': 'Days', @@ -1929,6 +1932,7 @@ const en: Record = { 'journey.editor.weather': 'Weather', 'journey.editor.photoFirst': '1st', 'journey.editor.makeFirst': 'Make 1st', + 'journey.editor.searching': 'Searching...', // Journey Entry — Moods 'journey.mood.amazing': 'Amazing', @@ -1984,6 +1988,13 @@ const en: Record = { 'journey.share.deleteFailed': 'Failed to delete', 'journey.share.updateFailed': 'Failed to update', + // Journey — Invite + 'journey.invite.role': 'Role', + 'journey.invite.viewer': 'Viewer', + 'journey.invite.editor': 'Editor', + 'journey.invite.invite': 'Invite', + 'journey.invite.inviting': 'Inviting...', + // Journey — Settings Dialog 'journey.settings.title': 'Journey Settings', 'journey.settings.coverImage': 'Cover Image', @@ -2019,6 +2030,16 @@ const en: Record = { 'journey.pdf.theEnd': 'The End', 'journey.pdf.saveAsPdf': 'Save as PDF', 'journey.pdf.pages': 'pages', + 'journey.picker.tripPeriod': 'Trip Period', + 'journey.picker.dateRange': 'Date Range', + 'journey.picker.allPhotos': 'All Photos', + 'journey.picker.albums': 'Albums', + 'journey.picker.selected': 'selected', + 'journey.picker.addTo': 'Add to', + 'journey.picker.newGallery': 'New Gallery', + 'journey.picker.noAlbums': 'No albums found', + 'journey.picker.selectDate': 'Select date', + 'journey.picker.search': 'Search', // Dashboard Mobile 'dashboard.greeting.morning': 'Good morning,', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 1b68816f..a176c833 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1906,6 +1906,9 @@ const es: Record = { 'journey.detail.contributors': 'Colaboradores', 'journey.detail.readMore': 'Leer más', 'journey.detail.prosCons': 'Pros y contras', + 'journey.detail.photos': 'fotos', + 'journey.detail.day': 'Día {number}', + 'journey.detail.places': 'lugares', 'journey.stats.days': 'Días', 'journey.stats.cities': 'Ciudades', 'journey.stats.entries': 'Entradas', @@ -1932,6 +1935,7 @@ const es: Record = { 'journey.editor.weather': 'Clima', 'journey.editor.photoFirst': '1º', 'journey.editor.makeFirst': 'Hacer 1º', + 'journey.editor.searching': 'Buscando...', 'journey.mood.amazing': 'Increíble', 'journey.mood.good': 'Bien', 'journey.mood.neutral': 'Neutral', @@ -1976,6 +1980,13 @@ const es: Record = { 'journey.share.linkDeleted': 'Enlace para compartir eliminado', 'journey.share.deleteFailed': 'No se pudo eliminar', 'journey.share.updateFailed': 'No se pudo actualizar', + + // Journey — Invite + 'journey.invite.role': 'Rol', + 'journey.invite.viewer': 'Lector', + 'journey.invite.editor': 'Editor', + 'journey.invite.invite': 'Invitar', + 'journey.invite.inviting': 'Invitando...', 'journey.settings.title': 'Ajustes de la travesía', 'journey.settings.coverImage': 'Imagen de portada', 'journey.settings.changeCover': 'Cambiar portada', @@ -2006,6 +2017,16 @@ const es: Record = { 'journey.pdf.theEnd': 'Fin', 'journey.pdf.saveAsPdf': 'Guardar como PDF', 'journey.pdf.pages': 'páginas', + 'journey.picker.tripPeriod': 'Período del viaje', + 'journey.picker.dateRange': 'Rango de fechas', + 'journey.picker.allPhotos': 'Todas las fotos', + 'journey.picker.albums': 'Álbumes', + 'journey.picker.selected': 'seleccionados', + 'journey.picker.addTo': 'Añadir a', + 'journey.picker.newGallery': 'Nueva galería', + 'journey.picker.noAlbums': 'No se encontraron álbumes', + 'journey.picker.selectDate': 'Seleccionar fecha', + 'journey.picker.search': 'Buscar', 'dashboard.greeting.morning': 'Buenos días,', 'dashboard.greeting.afternoon': 'Buenas tardes,', 'dashboard.greeting.evening': 'Buenas noches,', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index d424831d..ca9c1d91 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1900,6 +1900,9 @@ const fr: Record = { 'journey.detail.contributors': 'Contributeurs', 'journey.detail.readMore': 'Lire la suite', 'journey.detail.prosCons': 'Pour et contre', + 'journey.detail.photos': 'photos', + 'journey.detail.day': 'Jour {number}', + 'journey.detail.places': 'lieux', 'journey.stats.days': 'Jours', 'journey.stats.cities': 'Villes', 'journey.stats.entries': 'Entrées', @@ -1926,6 +1929,7 @@ const fr: Record = { 'journey.editor.weather': 'Météo', 'journey.editor.photoFirst': '1er', 'journey.editor.makeFirst': 'Mettre en 1er', + 'journey.editor.searching': 'Recherche...', 'journey.mood.amazing': 'Incroyable', 'journey.mood.good': 'Bien', 'journey.mood.neutral': 'Neutre', @@ -1970,6 +1974,13 @@ const fr: Record = { 'journey.share.linkDeleted': 'Lien de partage supprimé', 'journey.share.deleteFailed': 'Échec de la suppression', 'journey.share.updateFailed': 'Échec de la mise à jour', + + // Journey — Invite + 'journey.invite.role': 'Rôle', + 'journey.invite.viewer': 'Lecteur', + 'journey.invite.editor': 'Éditeur', + 'journey.invite.invite': 'Inviter', + 'journey.invite.inviting': 'Invitation...', 'journey.settings.title': 'Paramètres du journal', 'journey.settings.coverImage': 'Image de couverture', 'journey.settings.changeCover': 'Changer la couverture', @@ -2000,6 +2011,16 @@ const fr: Record = { 'journey.pdf.theEnd': 'Fin', 'journey.pdf.saveAsPdf': 'Enregistrer en PDF', 'journey.pdf.pages': 'pages', + 'journey.picker.tripPeriod': 'Période du voyage', + 'journey.picker.dateRange': 'Plage de dates', + 'journey.picker.allPhotos': 'Toutes les photos', + 'journey.picker.albums': 'Albums', + 'journey.picker.selected': 'sélectionnés', + 'journey.picker.addTo': 'Ajouter à', + 'journey.picker.newGallery': 'Nouvelle galerie', + 'journey.picker.noAlbums': 'Aucun album trouvé', + 'journey.picker.selectDate': 'Sélectionner une date', + 'journey.picker.search': 'Rechercher', 'dashboard.greeting.morning': 'Bonjour,', 'dashboard.greeting.afternoon': 'Bon après-midi,', 'dashboard.greeting.evening': 'Bonsoir,', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 759ef703..89d9d4df 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1901,6 +1901,9 @@ const hu: Record = { 'journey.detail.contributors': 'Közreműködők', 'journey.detail.readMore': 'Tovább olvasás', 'journey.detail.prosCons': 'Előnyök és hátrányok', + 'journey.detail.photos': 'fotók', + 'journey.detail.day': '{number}. nap', + 'journey.detail.places': 'helyek', 'journey.stats.days': 'Napok', 'journey.stats.cities': 'Városok', 'journey.stats.entries': 'Bejegyzések', @@ -1927,6 +1930,7 @@ const hu: Record = { 'journey.editor.weather': 'Időjárás', 'journey.editor.photoFirst': '1.', 'journey.editor.makeFirst': 'Legyen az 1.', + 'journey.editor.searching': 'Keresés...', 'journey.mood.amazing': 'Fantasztikus', 'journey.mood.good': 'Jó', 'journey.mood.neutral': 'Semleges', @@ -1971,6 +1975,13 @@ const hu: Record = { 'journey.share.linkDeleted': 'Megosztó link törölve', 'journey.share.deleteFailed': 'Nem sikerült törölni', 'journey.share.updateFailed': 'Nem sikerült frissíteni', + + // Journey — Invite + 'journey.invite.role': 'Szerepkör', + 'journey.invite.viewer': 'Megtekintő', + 'journey.invite.editor': 'Szerkesztő', + 'journey.invite.invite': 'Meghívás', + 'journey.invite.inviting': 'Meghívás...', 'journey.settings.title': 'Útinapló beállításai', 'journey.settings.coverImage': 'Borítókép', 'journey.settings.changeCover': 'Borító módosítása', @@ -2001,6 +2012,16 @@ const hu: Record = { 'journey.pdf.theEnd': 'Vége', 'journey.pdf.saveAsPdf': 'Mentés PDF-ként', 'journey.pdf.pages': 'oldal', + 'journey.picker.tripPeriod': 'Utazási időszak', + 'journey.picker.dateRange': 'Időszak', + 'journey.picker.allPhotos': 'Összes fotó', + 'journey.picker.albums': 'Albumok', + 'journey.picker.selected': 'kiválasztva', + 'journey.picker.addTo': 'Hozzáadás', + 'journey.picker.newGallery': 'Új galéria', + 'journey.picker.noAlbums': 'Nem található album', + 'journey.picker.selectDate': 'Dátum választása', + 'journey.picker.search': 'Keresés', 'dashboard.greeting.morning': 'Jó reggelt,', 'dashboard.greeting.afternoon': 'Jó napot,', 'dashboard.greeting.evening': 'Jó estét,', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index dabaf43a..7e1d1423 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1901,6 +1901,9 @@ const it: Record = { 'journey.detail.contributors': 'Contributori', 'journey.detail.readMore': 'Leggi di più', 'journey.detail.prosCons': 'Pro e contro', + 'journey.detail.photos': 'foto', + 'journey.detail.day': 'Giorno {number}', + 'journey.detail.places': 'luoghi', 'journey.stats.days': 'Giorni', 'journey.stats.cities': 'Città', 'journey.stats.entries': 'Voci', @@ -1927,6 +1930,7 @@ const it: Record = { 'journey.editor.weather': 'Meteo', 'journey.editor.photoFirst': '1°', 'journey.editor.makeFirst': 'Metti 1°', + 'journey.editor.searching': 'Ricerca...', 'journey.mood.amazing': 'Fantastico', 'journey.mood.good': 'Buono', 'journey.mood.neutral': 'Neutro', @@ -1971,6 +1975,13 @@ const it: Record = { 'journey.share.linkDeleted': 'Link di condivisione eliminato', 'journey.share.deleteFailed': 'Eliminazione fallita', 'journey.share.updateFailed': 'Aggiornamento fallito', + + // Journey — Invite + 'journey.invite.role': 'Ruolo', + 'journey.invite.viewer': 'Visualizzatore', + 'journey.invite.editor': 'Editore', + 'journey.invite.invite': 'Invita', + 'journey.invite.inviting': 'Invito in corso...', 'journey.settings.title': 'Impostazioni del diario', 'journey.settings.coverImage': 'Immagine di copertina', 'journey.settings.changeCover': 'Cambia copertina', @@ -2001,6 +2012,16 @@ const it: Record = { 'journey.pdf.theEnd': 'Fine', 'journey.pdf.saveAsPdf': 'Salva come PDF', 'journey.pdf.pages': 'pagine', + 'journey.picker.tripPeriod': 'Periodo del viaggio', + 'journey.picker.dateRange': 'Intervallo di date', + 'journey.picker.allPhotos': 'Tutte le foto', + 'journey.picker.albums': 'Album', + 'journey.picker.selected': 'selezionati', + 'journey.picker.addTo': 'Aggiungi a', + 'journey.picker.newGallery': 'Nuova galleria', + 'journey.picker.noAlbums': 'Nessun album trovato', + 'journey.picker.selectDate': 'Seleziona data', + 'journey.picker.search': 'Cerca', 'dashboard.greeting.morning': 'Buongiorno,', 'dashboard.greeting.afternoon': 'Buon pomeriggio,', 'dashboard.greeting.evening': 'Buonasera,', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 3f077d72..25981226 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1900,6 +1900,9 @@ const nl: Record = { 'journey.detail.contributors': 'Bijdragers', 'journey.detail.readMore': 'Lees meer', 'journey.detail.prosCons': 'Voor- & nadelen', + 'journey.detail.photos': 'foto\'s', + 'journey.detail.day': 'Dag {number}', + 'journey.detail.places': 'plaatsen', 'journey.stats.days': 'Dagen', 'journey.stats.cities': 'Steden', 'journey.stats.entries': 'Vermeldingen', @@ -1926,6 +1929,7 @@ const nl: Record = { 'journey.editor.weather': 'Weer', 'journey.editor.photoFirst': '1e', 'journey.editor.makeFirst': 'Maak 1e', + 'journey.editor.searching': 'Zoeken...', 'journey.mood.amazing': 'Fantastisch', 'journey.mood.good': 'Goed', 'journey.mood.neutral': 'Neutraal', @@ -1970,6 +1974,13 @@ const nl: Record = { 'journey.share.linkDeleted': 'Deellink verwijderd', 'journey.share.deleteFailed': 'Verwijderen mislukt', 'journey.share.updateFailed': 'Bijwerken mislukt', + + // Journey — Invite + 'journey.invite.role': 'Rol', + 'journey.invite.viewer': 'Kijker', + 'journey.invite.editor': 'Bewerker', + 'journey.invite.invite': 'Uitnodigen', + 'journey.invite.inviting': 'Uitnodigen...', 'journey.settings.title': 'Reisverslaginstellingen', 'journey.settings.coverImage': 'Omslagfoto', 'journey.settings.changeCover': 'Omslag wijzigen', @@ -2000,6 +2011,16 @@ const nl: Record = { 'journey.pdf.theEnd': 'Einde', 'journey.pdf.saveAsPdf': 'Opslaan als PDF', 'journey.pdf.pages': 'pagina\'s', + 'journey.picker.tripPeriod': 'Reisperiode', + 'journey.picker.dateRange': 'Datumbereik', + 'journey.picker.allPhotos': 'Alle foto\'s', + 'journey.picker.albums': 'Albums', + 'journey.picker.selected': 'geselecteerd', + 'journey.picker.addTo': 'Toevoegen aan', + 'journey.picker.newGallery': 'Nieuwe galerij', + 'journey.picker.noAlbums': 'Geen albums gevonden', + 'journey.picker.selectDate': 'Selecteer datum', + 'journey.picker.search': 'Zoeken', 'dashboard.greeting.morning': 'Goedemorgen,', 'dashboard.greeting.afternoon': 'Goedemiddag,', 'dashboard.greeting.evening': 'Goedenavond,', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 8d96eb66..ef99e1e6 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -1896,6 +1896,9 @@ const pl: Record = { 'journey.detail.contributors': 'Współtwórcy', 'journey.detail.readMore': 'Czytaj dalej', 'journey.detail.prosCons': 'Zalety i wady', + 'journey.detail.photos': 'zdjęć', + 'journey.detail.day': 'Dzień {number}', + 'journey.detail.places': 'miejsc', 'journey.stats.days': 'Dni', 'journey.stats.cities': 'Miasta', 'journey.stats.entries': 'Wpisy', @@ -1922,6 +1925,7 @@ const pl: Record = { 'journey.editor.weather': 'Pogoda', 'journey.editor.photoFirst': '1.', 'journey.editor.makeFirst': 'Ustaw jako 1.', + 'journey.editor.searching': 'Szukanie...', 'journey.mood.amazing': 'Niesamowity', 'journey.mood.good': 'Dobry', 'journey.mood.neutral': 'Neutralny', @@ -1966,6 +1970,13 @@ const pl: Record = { 'journey.share.linkDeleted': 'Link udostępniania usunięty', 'journey.share.deleteFailed': 'Usunięcie nie powiodło się', 'journey.share.updateFailed': 'Aktualizacja nie powiodła się', + + // Journey — Invite + 'journey.invite.role': 'Rola', + 'journey.invite.viewer': 'Obserwator', + 'journey.invite.editor': 'Redaktor', + 'journey.invite.invite': 'Zaproś', + 'journey.invite.inviting': 'Zapraszanie...', 'journey.settings.title': 'Ustawienia dziennika podróży', 'journey.settings.coverImage': 'Zdjęcie okładkowe', 'journey.settings.changeCover': 'Zmień okładkę', @@ -1996,6 +2007,16 @@ const pl: Record = { 'journey.pdf.theEnd': 'Koniec', 'journey.pdf.saveAsPdf': 'Zapisz jako PDF', 'journey.pdf.pages': 'stron', + 'journey.picker.tripPeriod': 'Okres podróży', + 'journey.picker.dateRange': 'Zakres dat', + 'journey.picker.allPhotos': 'Wszystkie zdjęcia', + 'journey.picker.albums': 'Albumy', + 'journey.picker.selected': 'wybranych', + 'journey.picker.addTo': 'Dodaj do', + 'journey.picker.newGallery': 'Nowa galeria', + 'journey.picker.noAlbums': 'Nie znaleziono albumów', + 'journey.picker.selectDate': 'Wybierz datę', + 'journey.picker.search': 'Szukaj', 'dashboard.greeting.morning': 'Dzień dobry,', 'dashboard.greeting.afternoon': 'Dzień dobry,', 'dashboard.greeting.evening': 'Dobry wieczór,', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 8a88c721..f5b49338 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1900,6 +1900,9 @@ const ru: Record = { 'journey.detail.contributors': 'Участники', 'journey.detail.readMore': 'Читать далее', 'journey.detail.prosCons': 'Плюсы и минусы', + 'journey.detail.photos': 'фото', + 'journey.detail.day': 'День {number}', + 'journey.detail.places': 'мест', 'journey.stats.days': 'Дней', 'journey.stats.cities': 'Городов', 'journey.stats.entries': 'Записей', @@ -1926,6 +1929,7 @@ const ru: Record = { 'journey.editor.weather': 'Погода', 'journey.editor.photoFirst': '1-е', 'journey.editor.makeFirst': 'Сделать 1-м', + 'journey.editor.searching': 'Поиск...', 'journey.mood.amazing': 'Потрясающе', 'journey.mood.good': 'Хорошо', 'journey.mood.neutral': 'Нейтрально', @@ -1970,6 +1974,13 @@ const ru: Record = { 'journey.share.linkDeleted': 'Ссылка удалена', 'journey.share.deleteFailed': 'Не удалось удалить', 'journey.share.updateFailed': 'Не удалось обновить', + + // Journey — Invite + 'journey.invite.role': 'Роль', + 'journey.invite.viewer': 'Наблюдатель', + 'journey.invite.editor': 'Редактор', + 'journey.invite.invite': 'Пригласить', + 'journey.invite.inviting': 'Приглашаем...', 'journey.settings.title': 'Настройки путешествия', 'journey.settings.coverImage': 'Обложка', 'journey.settings.changeCover': 'Сменить обложку', @@ -2000,6 +2011,16 @@ const ru: Record = { 'journey.pdf.theEnd': 'Конец', 'journey.pdf.saveAsPdf': 'Сохранить как PDF', 'journey.pdf.pages': 'страниц', + 'journey.picker.tripPeriod': 'Период поездки', + 'journey.picker.dateRange': 'Диапазон дат', + 'journey.picker.allPhotos': 'Все фото', + 'journey.picker.albums': 'Альбомы', + 'journey.picker.selected': 'выбрано', + 'journey.picker.addTo': 'Добавить в', + 'journey.picker.newGallery': 'Новая галерея', + 'journey.picker.noAlbums': 'Альбомы не найдены', + 'journey.picker.selectDate': 'Выберите дату', + 'journey.picker.search': 'Поиск', 'dashboard.greeting.morning': 'Доброе утро,', 'dashboard.greeting.afternoon': 'Добрый день,', 'dashboard.greeting.evening': 'Добрый вечер,', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 93ad9772..2ec537a5 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1900,6 +1900,9 @@ const zh: Record = { 'journey.detail.contributors': '贡献者', 'journey.detail.readMore': '阅读更多', 'journey.detail.prosCons': '优缺点', + 'journey.detail.photos': '照片', + 'journey.detail.day': '第{number}天', + 'journey.detail.places': '个地点', 'journey.stats.days': '天', 'journey.stats.cities': '城市', 'journey.stats.entries': '条目', @@ -1910,7 +1913,7 @@ const zh: Record = { 'journey.synced.places': '个地点', 'journey.synced.synced': '已同步', 'journey.editor.uploadPhotos': '上传照片', - 'journey.editor.fromGallery': '从相册选择', + 'journey.editor.fromGallery': '从相册', 'journey.editor.allPhotosAdded': '所有照片已添加', 'journey.editor.writeStory': '写下你的故事...', 'journey.editor.prosCons': '优缺点', @@ -1926,6 +1929,7 @@ const zh: Record = { 'journey.editor.weather': '天气', 'journey.editor.photoFirst': '第1张', 'journey.editor.makeFirst': '设为第1张', + 'journey.editor.searching': '搜索中...', 'journey.mood.amazing': '太棒了', 'journey.mood.good': '不错', 'journey.mood.neutral': '一般', @@ -1970,6 +1974,13 @@ const zh: Record = { 'journey.share.linkDeleted': '分享链接已删除', 'journey.share.deleteFailed': '删除失败', 'journey.share.updateFailed': '更新失败', + + // Journey — Invite + 'journey.invite.role': '角色', + 'journey.invite.viewer': '查看者', + 'journey.invite.editor': '编辑者', + 'journey.invite.invite': '邀请', + 'journey.invite.inviting': '邀请中...', 'journey.settings.title': '旅程设置', 'journey.settings.coverImage': '封面图片', 'journey.settings.changeCover': '更换封面', @@ -2000,6 +2011,16 @@ const zh: Record = { 'journey.pdf.theEnd': '终', 'journey.pdf.saveAsPdf': '保存为 PDF', 'journey.pdf.pages': '页', + 'journey.picker.tripPeriod': '旅行时间段', + 'journey.picker.dateRange': '日期范围', + 'journey.picker.allPhotos': '所有照片', + 'journey.picker.albums': '相册', + 'journey.picker.selected': '已选择', + 'journey.picker.addTo': '添加到', + 'journey.picker.newGallery': '新相册', + 'journey.picker.noAlbums': '未找到相册', + 'journey.picker.selectDate': '选择日期', + 'journey.picker.search': '搜索', 'dashboard.greeting.morning': '早上好,', 'dashboard.greeting.afternoon': '下午好,', 'dashboard.greeting.evening': '晚上好,', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 49e38eba..248f33a1 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -1861,6 +1861,9 @@ const zhTw: Record = { 'journey.detail.contributors': '貢獻者', 'journey.detail.readMore': '閱讀更多', 'journey.detail.prosCons': '優缺點', + 'journey.detail.photos': '照片', + 'journey.detail.day': '第{number}天', + 'journey.detail.places': '個地點', 'journey.stats.days': '天', 'journey.stats.cities': '城市', 'journey.stats.entries': '條目', @@ -1871,7 +1874,7 @@ const zhTw: Record = { 'journey.synced.places': '個地點', 'journey.synced.synced': '已同步', 'journey.editor.uploadPhotos': '上傳照片', - 'journey.editor.fromGallery': '從相簿選擇', + 'journey.editor.fromGallery': '從相簿', 'journey.editor.allPhotosAdded': '所有照片已新增', 'journey.editor.writeStory': '寫下你的故事...', 'journey.editor.prosCons': '優缺點', @@ -1887,6 +1890,7 @@ const zhTw: Record = { 'journey.editor.weather': '天氣', 'journey.editor.photoFirst': '第1張', 'journey.editor.makeFirst': '設為第1張', + 'journey.editor.searching': '搜尋中...', 'journey.mood.amazing': '太棒了', 'journey.mood.good': '不錯', 'journey.mood.neutral': '一般', @@ -1931,6 +1935,13 @@ const zhTw: Record = { 'journey.share.linkDeleted': '分享連結已刪除', 'journey.share.deleteFailed': '刪除失敗', 'journey.share.updateFailed': '更新失敗', + + // Journey — Invite + 'journey.invite.role': '角色', + 'journey.invite.viewer': '檢視者', + 'journey.invite.editor': '編輯者', + 'journey.invite.invite': '邀請', + 'journey.invite.inviting': '邀請中...', 'journey.settings.title': '旅程設定', 'journey.settings.coverImage': '封面圖片', 'journey.settings.changeCover': '更換封面', @@ -1961,6 +1972,16 @@ const zhTw: Record = { 'journey.pdf.theEnd': '終', 'journey.pdf.saveAsPdf': '儲存為 PDF', 'journey.pdf.pages': '頁', + 'journey.picker.tripPeriod': '旅行期間', + 'journey.picker.dateRange': '日期範圍', + 'journey.picker.allPhotos': '所有照片', + 'journey.picker.albums': '相簿', + 'journey.picker.selected': '已選擇', + 'journey.picker.addTo': '新增至', + 'journey.picker.newGallery': '新相簿', + 'journey.picker.noAlbums': '未找到相簿', + 'journey.picker.selectDate': '選擇日期', + 'journey.picker.search': '搜尋', 'dashboard.greeting.morning': '早安,', 'dashboard.greeting.afternoon': '午安,', 'dashboard.greeting.evening': '晚安,', diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index d27d0ad8..3f3ed472 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -113,6 +113,7 @@ const mockJourneyDetail = { { id: 100, entry_id: 10, + photo_id: 100, provider: 'local', file_path: 'photos/test.jpg', asset_id: null, @@ -547,17 +548,17 @@ describe('JourneyDetailPage', () => { ...mockJourneyDetail.entries[0], photos: [ { - id: 100, entry_id: 10, provider: 'local' as const, file_path: 'photos/a.jpg', + id: 100, entry_id: 10, photo_id: 100, provider: 'local' as const, file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now, }, { - id: 101, entry_id: 10, provider: 'local' as const, file_path: 'photos/b.jpg', + id: 101, entry_id: 10, photo_id: 101, provider: 'local' as const, file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now, }, { - id: 102, entry_id: 10, provider: 'local' as const, file_path: 'photos/c.jpg', + id: 102, entry_id: 10, photo_id: 102, provider: 'local' as const, file_path: 'photos/c.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now, }, @@ -1991,7 +1992,7 @@ describe('JourneyDetailPage', () => { const immichEntry = { ...mockJourneyDetail.entries[0], photos: [{ - id: 200, entry_id: 10, provider: 'immich', file_path: null, + id: 200, entry_id: 10, photo_id: 200, provider: 'immich', file_path: null, asset_id: 'asset-123', owner_id: 1, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now, }], @@ -2025,7 +2026,7 @@ describe('JourneyDetailPage', () => { const synologyEntry = { ...mockJourneyDetail.entries[0], photos: [{ - id: 201, entry_id: 10, provider: 'synology', file_path: null, + id: 201, entry_id: 10, photo_id: 201, provider: 'synology', file_path: null, asset_id: 'syn-456', owner_id: 1, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now, }], @@ -2617,11 +2618,11 @@ describe('JourneyDetailPage', () => { const multiPhotoEntry = { ...mockJourneyDetail.entries[0], photos: [ - { id: 100, entry_id: 10, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now }, - { id: 101, entry_id: 10, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now }, - { id: 102, entry_id: 10, provider: 'local', file_path: 'photos/c.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now }, - { id: 103, entry_id: 10, provider: 'local', file_path: 'photos/d.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 3, width: 800, height: 600, shared: 1, created_at: now }, - { id: 104, entry_id: 10, provider: 'local', file_path: 'photos/e.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 4, width: 800, height: 600, shared: 1, created_at: now }, + { id: 100, entry_id: 10, photo_id: 100, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now }, + { id: 101, entry_id: 10, photo_id: 101, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now }, + { id: 102, entry_id: 10, photo_id: 102, provider: 'local', file_path: 'photos/c.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now }, + { id: 103, entry_id: 10, photo_id: 103, provider: 'local', file_path: 'photos/d.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 3, width: 800, height: 600, shared: 1, created_at: now }, + { id: 104, entry_id: 10, photo_id: 104, provider: 'local', file_path: 'photos/e.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 4, width: 800, height: 600, shared: 1, created_at: now }, ], }; setupDefaultHandlers({ @@ -2645,8 +2646,8 @@ describe('JourneyDetailPage', () => { const twoPhotoEntry = { ...mockJourneyDetail.entries[0], photos: [ - { id: 100, entry_id: 10, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now }, - { id: 101, entry_id: 10, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now }, + { id: 100, entry_id: 10, photo_id: 100, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now }, + { id: 101, entry_id: 10, photo_id: 101, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now }, ], }; setupDefaultHandlers({ @@ -3344,7 +3345,7 @@ describe('JourneyDetailPage', () => { }), http.post('/api/journeys/entries/88/photos', () => { uploadCalled = true; - return HttpResponse.json([{ id: 999, entry_id: 88, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]); + return HttpResponse.json([{ id: 999, entry_id: 88, photo_id: 999, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]); }), ); @@ -3510,8 +3511,8 @@ describe('JourneyDetailPage', () => { const entryWithMultiPhotos = { ...mockJourneyDetail.entries[0], photos: [ - { id: 100, entry_id: 10, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now }, - { id: 101, entry_id: 10, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now }, + { id: 100, entry_id: 10, photo_id: 100, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now }, + { id: 101, entry_id: 10, photo_id: 101, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now }, ], }; setupDefaultHandlers({ @@ -3564,7 +3565,7 @@ describe('JourneyDetailPage', () => { }), http.post('/api/journeys/entries/11/photos', () => { uploadCalled = true; - return HttpResponse.json([{ id: 300, entry_id: 11, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]); + return HttpResponse.json([{ id: 300, entry_id: 11, photo_id: 300, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]); }), ); diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index f038ad0a..73710b8a 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -64,20 +64,14 @@ function groupByDate(entries: JourneyEntry[]): Map { function formatDate(d: string): { weekday: string; month: string; day: number } { const date = new Date(d + 'T00:00:00') return { - weekday: date.toLocaleDateString('en', { weekday: 'long' }), - month: date.toLocaleDateString('en', { month: 'long' }), + weekday: date.toLocaleDateString(undefined, { weekday: 'long' }), + month: date.toLocaleDateString(undefined, { month: 'long' }), day: date.getDate(), } } function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string { - if (p.provider === 'local') { - return `/uploads/${p.file_path}` - } - // Immich / Synology — stream through the existing memories proxy - // tripId=0 is a placeholder, the proxy uses owner_id to find credentials - const kind = size === 'thumbnail' ? 'thumbnail' : 'original' - return `/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/${kind}` + return `/api/photos/${p.photo_id}/${size}` } export default function JourneyDetailPage() { @@ -195,7 +189,7 @@ export default function JourneyDetailPage() { {/* Back link — desktop */} {/* Hero card — full width */} @@ -220,7 +214,7 @@ export default function JourneyDetailPage() { )}
- Synced with Trips + {t('journey.detail.syncedWithTrips')}
{/* Mobile: back button on the left */} @@ -326,7 +320,7 @@ export default function JourneyDetailPage() { {dayIdx + 1}
-

{fd.weekday}, {fd.month} {fd.day}

+

{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}

@@ -405,17 +399,17 @@ export default function JourneyDetailPage() { {/* Stats panel */}
-
{t('journey.detail.journeyStats')}
-
+
{t('journey.detail.journeyStats')}
+
{[ - { value: `${sortedDates.length}`, label: t('journey.stats.days') }, - { value: `${current.stats.entries}`, label: t('journey.stats.entries') }, - { value: `${current.stats.photos}`, label: t('journey.stats.photos') }, - { value: `${current.stats.cities}`, label: t('journey.stats.cities') }, + { value: sortedDates.length, label: t('journey.stats.days') }, + { value: current.stats.entries, label: t('journey.stats.entries') }, + { value: current.stats.photos, label: t('journey.stats.photos') }, + { value: current.stats.cities, label: t('journey.stats.cities') }, ].map(s => ( -
-
{s.value}
-
{s.label}
+
+
{s.value}
+
{s.label}
))}
@@ -440,7 +434,7 @@ export default function JourneyDetailPage() {
{trip.title}
- {trip.place_count || 0} places + {trip.place_count || 0} {t('journey.detail.places')} {t('journey.synced.synced')}
@@ -674,7 +668,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
{/* Day separator */}
- Day {dayIdx + 1} + {t('journey.detail.day', { number: dayIdx + 1 })} {fd.month} {fd.day}
@@ -834,14 +828,16 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres {/* Header */}
- {allPhotos.length} photos + + {allPhotos.length} {t('journey.detail.photos')} +
{availableProviders.map(p => (
@@ -1272,7 +1268,7 @@ function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => v } function PhotoImg({ photo, className, style, onClick }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties; onClick?: () => void }) { - const src = photo.provider !== 'local' ? photoUrl(photo, 'original') : photoUrl(photo) + const src = photoUrl(photo, 'original') return ( Promise }) { const { t } = useTranslation() - const [filter, setFilter] = useState<'trip' | 'custom' | 'album'>('trip') + const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip') const [photos, setPhotos] = useState([]) const [albums, setAlbums] = useState([]) const [selectedAlbum, setSelectedAlbum] = useState(null) @@ -1413,6 +1409,8 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on useEffect(() => { if (filter === 'trip' && tripRange.from && tripRange.to) { searchPhotos(tripRange.from, tripRange.to) + } else if (filter === 'all') { + searchPhotos('', '') } else if (filter === 'album' && albums.length === 0) { loadAlbums() } @@ -1432,7 +1430,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on const targetLabel = targetEntryId ? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries') - : 'Gallery' + : t('journey.picker.newGallery') return (
@@ -1453,9 +1451,10 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on {/* Tabs */}
{[ - { id: 'trip' as const, label: t('journey.trips.link') }, - { id: 'custom' as const, label: t('common.edit') }, - { id: 'album' as const, label: t('journey.share.gallery') }, + { id: 'trip' as const, label: t('journey.picker.tripPeriod') }, + { id: 'custom' as const, label: t('journey.picker.dateRange') }, + { id: 'all' as const, label: t('journey.picker.allPhotos') }, + { id: 'album' as const, label: t('journey.picker.albums') }, ].map(f => (
)} @@ -1522,16 +1521,16 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on {a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''} ))} - {albums.length === 0 && !loading && No albums found} + {albums.length === 0 && !loading && {t('journey.picker.noAlbums')}}
)}
- {/* Add-to */} + {/* Add-to entry selector */}
- Add to + {t('journey.picker.addTo')} -
- {entries.map(e => ( + {entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && ( +
+ )} + {entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => ( ))}
@@ -1638,19 +1639,20 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on {/* Footer */}
- - {selected.size} selected + + {selected.size} + {t('journey.picker.selected')}
@@ -1666,6 +1668,7 @@ function DatePicker({ value, onChange, tripDates }: { onChange: (date: string) => void tripDates?: Set }) { + const { t } = useTranslation() const [open, setOpen] = useState(false) const [viewMonth, setViewMonth] = useState(() => { const d = value ? new Date(value + 'T00:00:00') : new Date() @@ -1674,7 +1677,7 @@ function DatePicker({ value, onChange, tripDates }: { const daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate() const firstDow = new Date(viewMonth.year, viewMonth.month, 1).getDay() - const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString('en', { month: 'long', year: 'numeric' }) + const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) const prevMonth = () => { setViewMonth(p => p.month === 0 ? { year: p.year - 1, month: 11 } : { ...p, month: p.month - 1 }) @@ -1689,7 +1692,7 @@ function DatePicker({ value, onChange, tripDates }: { for (let i = 0; i < firstDow; i++) cells.push(null) for (let d = 1; d <= daysInMonth; d++) cells.push(d) - const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' }) : 'Select date' + const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : t('journey.picker.selectDate') return (
@@ -1719,8 +1722,8 @@ function DatePicker({ value, onChange, tripDates }: { {/* Weekday headers */}
- {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(d => ( -
{d}
+ {Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i).toLocaleDateString(undefined, { weekday: 'narrow' })).map((d, i) => ( +
{d}
))}
@@ -1870,7 +1873,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa onClick={() => fileRef.current?.click()} className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5" > - Upload photos + {t('journey.editor.uploadPhotos')} {galleryPhotos.length > 0 && ( )}
@@ -1906,7 +1909,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa }} className="aspect-square rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all" > - { if (gp.provider !== 'local') { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig } }} /> + { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
))} {galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && ( @@ -1920,7 +1923,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
{photos.map((p, idx) => (
1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}> - { if (p.provider !== 'local') { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig } }} /> + { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} /> {idx === 0 && photos.length > 1 && ( {t('journey.editor.photoFirst')} )} @@ -1938,7 +1941,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa }} className="absolute bottom-0.5 left-0.5 px-1.5 py-0.5 rounded bg-black/60 text-white text-[8px] font-semibold opacity-0 group-hover:opacity-100 transition-opacity" > - Make 1st + {t('journey.editor.makeFirst')} )}
@@ -2051,7 +2054,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa onClick={() => setCons([...cons, ''])} className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-red-200 dark:border-red-800/40 rounded-[10px] text-[12px] font-medium text-red-700 dark:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-colors" > - Add another + {t('journey.editor.addAnother')}
@@ -2129,7 +2132,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa )} {locationSearching && (
- Searching... + {t('journey.editor.searching')}
)}
@@ -2268,7 +2271,7 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: { disabled={adding === t.id} className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200 disabled:opacity-50" > - {adding === t.id ? '...' : 'Link'} + {adding === t.id ? '...' : t('journey.trips.link')}
))} @@ -2376,19 +2379,19 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite {/* Role selector */}
- +
{(['viewer', 'editor'] as const).map(r => ( ))}
@@ -2397,14 +2400,14 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
@@ -2471,7 +2474,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) { onClick={createLink} className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors" > - Create share link + {t('journey.share.createLink')} ) : (
@@ -2483,7 +2486,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) { onClick={copyLink} className="flex-shrink-0 px-2.5 py-1 rounded-md bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-700 dark:hover:bg-zinc-200" > - {copied ? 'Copied!' : 'Copy'} + {copied ? t('journey.share.copied') : t('journey.share.copy')}
diff --git a/client/src/pages/JourneyPublicPage.test.tsx b/client/src/pages/JourneyPublicPage.test.tsx index 0d4ece52..fbeef095 100644 --- a/client/src/pages/JourneyPublicPage.test.tsx +++ b/client/src/pages/JourneyPublicPage.test.tsx @@ -97,7 +97,7 @@ const mockJourneyData = { weather: 'cloudy', pros_cons: null, photos: [ - { id: 100, entry_id: 11, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance' }, + { id: 100, entry_id: 11, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance' }, ], }, ], @@ -348,9 +348,9 @@ describe('JourneyPublicPage', () => { entry_time: null, location_name: null, location_lat: null, location_lng: null, mood: null, weather: null, pros_cons: null, photos: [ - { id: 200, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A' }, - { id: 201, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B' }, - { id: 202, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C' }, + { id: 200, entry_id: 20, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A' }, + { id: 201, entry_id: 20, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B' }, + { id: 202, entry_id: 20, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C' }, ], }, ], diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index 8fa60d9c..16213ff2 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -26,7 +26,8 @@ interface PublicEntry { interface PublicPhoto { id: number entry_id: number - provider: string + photo_id: number + provider?: string asset_id?: string | null owner_id?: number | null file_path?: string | null @@ -34,8 +35,7 @@ interface PublicPhoto { } function photoUrl(p: PublicPhoto, shareToken: string): string { - if (p.provider === 'local') return `/api/public/journey/${shareToken}/photo/local/${encodeURIComponent(p.file_path || '')}/0/original` - return `/api/public/journey/${shareToken}/photo/${p.provider}/${p.asset_id}/${p.owner_id}/original` + return `/api/public/journey/${shareToken}/photos/${p.photo_id}/original` } function formatDate(d: string): { weekday: string; month: string; day: number } { diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index c643117d..51abdb1e 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -42,17 +42,19 @@ export interface JourneyEntry { export interface JourneyPhoto { id: number entry_id: number - provider: 'local' | 'immich' | 'synologyphotos' + photo_id: number + caption?: string | null + sort_order: number + shared: number + created_at: number + // Joined from trek_photos for display + provider?: string asset_id?: string | null owner_id?: number | null file_path?: string | null thumbnail_path?: string | null - caption?: string | null - sort_order: number width?: number | null height?: number | null - shared: number - created_at: number } export interface JourneyTrip { diff --git a/server/src/app.ts b/server/src/app.ts index 3bf2336a..4187dc44 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -36,6 +36,7 @@ import { oauthPublicRouter, oauthApiRouter } from './routes/oauth'; import vacayRoutes from './routes/vacay'; import atlasRoutes from './routes/atlas'; import memoriesRoutes from './routes/memories/unified'; +import photoRoutes from './routes/photos'; import notificationRoutes from './routes/notifications'; import shareRoutes from './routes/share'; import journeyRoutes from './routes/journey'; @@ -265,6 +266,7 @@ export function createApp(): express.Application { app.use('/api/journeys', journeyRoutes); app.use('/api/public/journey', journeyPublicRoutes); app.use('/api/integrations/memories', memoriesRoutes); + app.use('/api/photos', photoRoutes); app.use('/api/maps', mapsRoutes); app.use('/api/weather', weatherRoutes); app.use('/api/settings', settingsRoutes); diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f614ff4d..78376e87 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1435,6 +1435,115 @@ function runMigrations(db: Database.Database): void { () => { try { db.exec("ALTER TABLE vacay_plans ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1"); } catch {} }, + // Migration: Unified Photo Provider Abstraction Layer (#584) + // Central trek_photos registry; trip_photos + journey_photos reference via photo_id + () => { + // 1. Create the central photo registry + db.exec(` + CREATE TABLE IF NOT EXISTS trek_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + asset_id TEXT, + owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + file_path TEXT, + thumbnail_path TEXT, + width INTEGER, + height INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_trek_photos_provider_asset ON trek_photos(provider, asset_id, owner_id) WHERE asset_id IS NOT NULL'); + db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_owner ON trek_photos(owner_id)'); + + // 2. Migrate trip_photos → trek_photos + photo_id FK + const tripPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'").get(); + if (tripPhotosExists) { + // Insert existing trip photo references into trek_photos (deduplicate by provider+asset_id+owner) + db.exec(` + INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, created_at) + SELECT DISTINCT provider, asset_id, user_id, COALESCE(added_at, CURRENT_TIMESTAMP) + FROM trip_photos + WHERE asset_id IS NOT NULL AND TRIM(asset_id) != '' + `); + + // Recreate trip_photos with photo_id FK + db.exec(` + CREATE TABLE trip_photos_new ( + 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, + photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, + shared INTEGER NOT NULL DEFAULT 1, + album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, photo_id) + ) + `); + db.exec(` + INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, photo_id, shared, album_link_id, added_at) + SELECT tp.trip_id, tp.user_id, tkp.id, tp.shared, tp.album_link_id, tp.added_at + FROM trip_photos tp + JOIN trek_photos tkp ON tkp.provider = tp.provider AND tkp.asset_id = tp.asset_id AND tkp.owner_id = tp.user_id + `); + db.exec('DROP TABLE trip_photos'); + db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos'); + db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_photo ON trip_photos(photo_id)'); + } + + // 3. Migrate journey_photos → trek_photos + photo_id FK + const journeyPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'").get(); + if (journeyPhotosExists) { + // Insert provider-based journey photos into trek_photos + db.exec(` + INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, width, height, created_at) + SELECT DISTINCT provider, asset_id, owner_id, width, height, created_at + FROM journey_photos + WHERE provider != 'local' AND asset_id IS NOT NULL AND TRIM(asset_id) != '' + `); + // Insert local journey photos into trek_photos (each is unique) + db.exec(` + INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, created_at) + SELECT 'local', file_path, thumbnail_path, width, height, created_at + FROM journey_photos + WHERE provider = 'local' AND file_path IS NOT NULL + `); + + // Recreate journey_photos with photo_id FK + db.exec(` + CREATE TABLE journey_photos_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, + caption TEXT, + sort_order INTEGER DEFAULT 0, + shared INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE + ) + `); + // Migrate provider photos + db.exec(` + INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at) + SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at + FROM journey_photos jp + JOIN trek_photos tkp ON tkp.provider = jp.provider AND tkp.asset_id = jp.asset_id AND tkp.owner_id = jp.owner_id + WHERE jp.provider != 'local' AND jp.asset_id IS NOT NULL + `); + // Migrate local photos (match by file_path) + db.exec(` + INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at) + SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at + FROM journey_photos jp + JOIN trek_photos tkp ON tkp.provider = 'local' AND tkp.file_path = jp.file_path + WHERE jp.provider = 'local' AND jp.file_path IS NOT NULL + `); + db.exec('DROP TABLE journey_photos'); + db.exec('ALTER TABLE journey_photos_new RENAME TO journey_photos'); + db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)'); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/journeyPublic.ts b/server/src/routes/journeyPublic.ts index 0bd1fac0..37dd167e 100644 --- a/server/src/routes/journeyPublic.ts +++ b/server/src/routes/journeyPublic.ts @@ -1,5 +1,6 @@ import express, { Request, Response } from 'express'; -import { getPublicJourney, validateShareTokenForAsset } from '../services/journeyShareService'; +import { getPublicJourney, validateShareTokenForAsset, validateShareTokenForPhoto } from '../services/journeyShareService'; +import { streamPhoto } from '../services/memories/photoResolverService'; import { streamImmichAsset } from '../services/memories/immichService'; import path from 'node:path'; import fs from 'node:fs'; @@ -12,16 +13,23 @@ router.get('/:token', (req: Request, res: Response) => { res.json(data); }); -// Public photo proxy — validates share token instead of auth +// Unified public photo proxy — uses trek_photo_id +router.get('/:token/photos/:photoId/:kind', async (req: Request, res: Response) => { + const { token, photoId, kind } = req.params; + const valid = validateShareTokenForPhoto(token, Number(photoId)); + if (!valid) return res.status(404).json({ error: 'Not found' }); + + await streamPhoto(res, valid.ownerId, Number(photoId), kind === 'thumbnail' ? 'thumbnail' : 'original'); +}); + +// Legacy public photo proxy — validates share token instead of auth router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => { const { token, provider, assetId, ownerId, kind } = req.params; - // Validate token and that this asset belongs to the shared journey const valid = validateShareTokenForAsset(token, assetId); if (!valid) return res.status(404).json({ error: 'Not found' }); if (provider === 'local') { - // Local file — assetId is the file_path const filePath = path.join(__dirname, '../../uploads/journey', assetId); const resolved = path.resolve(filePath); const uploadsDir = path.resolve(__dirname, '../../uploads'); @@ -32,12 +40,10 @@ router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Reques return res.sendFile(resolved); } - // Immich/Synology — proxy through const effectiveOwnerId = valid.ownerId || Number(ownerId); if (provider === 'immich') { await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId); } else { - // Synology or other providers — try dynamic import try { const { streamSynologyAsset } = await import('../services/memories/synologyService'); await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original'); diff --git a/server/src/routes/memories/unified.ts b/server/src/routes/memories/unified.ts index 569bb2b8..303d1170 100644 --- a/server/src/routes/memories/unified.ts +++ b/server/src/routes/memories/unified.ts @@ -55,8 +55,7 @@ router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Re const result = await setTripPhotoSharing( tripId, authReq.user.id, - req.body?.provider, - req.body?.asset_id, + Number(req.body?.photo_id), req.body?.shared, ); if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); @@ -66,7 +65,7 @@ router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Re router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const result = await removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id); + const result = removeTripPhoto(tripId, authReq.user.id, Number(req.body?.photo_id)); if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); res.json({ success: true }); }); diff --git a/server/src/routes/photos.ts b/server/src/routes/photos.ts new file mode 100644 index 00000000..f2f794b0 --- /dev/null +++ b/server/src/routes/photos.ts @@ -0,0 +1,47 @@ +import express, { Request, Response } from 'express'; +import { authenticate } from '../middleware/auth'; +import { AuthRequest } from '../types'; +import { streamPhoto, getPhotoInfo, resolveTrekPhoto } from '../services/memories/photoResolverService'; +import { canAccessTrekPhoto } from '../services/memories/helpersService'; + +const router = express.Router(); + +router.get('/:id/thumbnail', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const photoId = Number(req.params.id); + if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' }); + + if (!canAccessTrekPhoto(authReq.user.id, photoId)) { + return res.status(403).json({ error: 'Forbidden' }); + } + + await streamPhoto(res, authReq.user.id, photoId, 'thumbnail'); +}); + +router.get('/:id/original', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const photoId = Number(req.params.id); + if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' }); + + if (!canAccessTrekPhoto(authReq.user.id, photoId)) { + return res.status(403).json({ error: 'Forbidden' }); + } + + await streamPhoto(res, authReq.user.id, photoId, 'original'); +}); + +router.get('/:id/info', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const photoId = Number(req.params.id); + if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' }); + + if (!canAccessTrekPhoto(authReq.user.id, photoId)) { + return res.status(403).json({ error: 'Forbidden' }); + } + + const result = await getPhotoInfo(authReq.user.id, photoId); + if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); + res.json(result.data); +}); + +export default router; diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts index 7d25ef1a..e97fde38 100644 --- a/server/src/services/journeyService.ts +++ b/server/src/services/journeyService.ts @@ -1,11 +1,19 @@ import { db } from '../db/database'; import { broadcastToUser } from '../websocket'; import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types'; +import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider } from './memories/photoResolverService'; function ts(): number { return Date.now(); } +// Joined SELECT for journey_photos + trek_photos — returns fields matching JourneyPhoto interface +const JP_SELECT = ` + jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at, + tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height +`; +const JP_JOIN = 'journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id'; + function broadcastJourneyEvent(journeyId: number, event: string, data: Record, excludeUserId?: number) { const contributors = db.prepare( 'SELECT user_id FROM journey_contributors WHERE journey_id = ?' @@ -105,7 +113,7 @@ export function getJourneyFull(journeyId: number, userId: number) { ).all(journeyId) as JourneyEntry[]; const photos = db.prepare( - 'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC' + `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC` ).all(journeyId) as JourneyPhoto[]; // group photos by entry @@ -272,8 +280,8 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb // import trip_photos into journey when a trip is linked function syncTripPhotos(journeyId: number, tripId: number) { const tripPhotos = db.prepare( - 'SELECT * FROM trip_photos WHERE trip_id = ?' - ).all(tripId) as { id: number; trip_id: number; user_id: number; asset_id: string; provider: string; shared: number }[]; + 'SELECT tp.photo_id, tp.user_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?' + ).all(tripId) as { photo_id: number; user_id: number; shared: number }[]; if (!tripPhotos.length) return; const now = ts(); @@ -285,7 +293,6 @@ function syncTripPhotos(journeyId: number, tripId: number) { `).get(journeyId, tripId) as { id: number } | undefined; if (!photoEntry) { - // get trip date for the entry const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined; const entryDate = trip?.start_date || new Date().toISOString().split('T')[0]; const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number }; @@ -297,19 +304,19 @@ function syncTripPhotos(journeyId: number, tripId: number) { photoEntry = { id: Number(res.lastInsertRowid) }; } - // import each trip photo, skip duplicates + // import each trip photo, skip duplicates (by photo_id) for (const tp of tripPhotos) { const exists = db.prepare( - 'SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?' - ).get(photoEntry.id, tp.provider, tp.asset_id); + 'SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?' + ).get(photoEntry.id, tp.photo_id); if (exists) continue; const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null }; db.prepare(` - INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, shared, sort_order, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `).run(photoEntry.id, tp.provider, tp.asset_id, tp.user_id, tp.shared, (maxOrder?.m ?? -1) + 1, now); + INSERT INTO journey_photos (entry_id, photo_id, shared, sort_order, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(photoEntry.id, tp.photo_id, tp.shared, (maxOrder?.m ?? -1) + 1, now); } } @@ -424,7 +431,7 @@ export function listEntries(journeyId: number, userId: number) { ).all(journeyId) as JourneyEntry[]; const photos = db.prepare( - 'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC' + `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC` ).all(journeyId) as JourneyPhoto[]; const photosByEntry: Record = {}; @@ -579,15 +586,16 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum if (!entry) return null; if (!canEdit(entry.journey_id, userId)) return null; + const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath); const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null }; const now = ts(); const res = db.prepare(` - INSERT INTO journey_photos (entry_id, provider, file_path, thumbnail_path, caption, sort_order, created_at) - VALUES (?, 'local', ?, ?, ?, ?, ?) - `).run(entryId, filePath, thumbnailPath || null, caption || null, (maxOrder?.m ?? -1) + 1, now); + INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now); - return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto; + return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto; } export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null { @@ -595,19 +603,21 @@ export function addProviderPhoto(entryId: number, userId: number, provider: stri if (!entry) return null; if (!canEdit(entry.journey_id, userId)) return null; + const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId); + // skip if already added - const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?').get(entryId, provider, assetId); + const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId); if (exists) return null; const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null }; const now = ts(); const res = db.prepare(` - INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, caption, sort_order, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `).run(entryId, provider, assetId, userId, caption || null, (maxOrder?.m ?? -1) + 1, now); + INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now); - return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto; + return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto; } export function linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null { @@ -615,7 +625,7 @@ export function linkPhotoToEntry(entryId: number, photoId: number, userId: numbe if (!entry) return null; if (!canEdit(entry.journey_id, userId)) return null; - const source = db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto | undefined; + const source = db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto | undefined; if (!source) return null; if (source.entry_id === entryId) return source; @@ -634,16 +644,19 @@ export function linkPhotoToEntry(entryId: number, photoId: number, userId: numbe } } - return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto; + return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto; } export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { - db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(provider, assetId, ownerId, photoId); + // Get the trek_photo_id from the journey_photo, then update the central registry + const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as { photo_id: number } | undefined; + if (!jp) return; + setTrekPhotoProvider(jp.photo_id, provider, assetId, ownerId); } export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null { const photo = db.prepare(` - SELECT jp.*, je.journey_id FROM journey_photos jp + SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN} JOIN journey_entries je ON jp.entry_id = je.id WHERE jp.id = ? `).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined; @@ -658,12 +671,12 @@ export function updatePhoto(photoId: number, userId: number, data: { caption?: s values.push(photoId); db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values); - return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto; + return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto; } export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null { const photo = db.prepare(` - SELECT jp.*, je.journey_id FROM journey_photos jp + SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN} JOIN journey_entries je ON jp.entry_id = je.id WHERE jp.id = ? `).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined; diff --git a/server/src/services/journeyShareService.ts b/server/src/services/journeyShareService.ts index cef79a5e..46ef6926 100644 --- a/server/src/services/journeyShareService.ts +++ b/server/src/services/journeyShareService.ts @@ -59,7 +59,9 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any; if (!row) return null; const photo = db.prepare(` - SELECT jp.*, je.journey_id FROM journey_photos jp + SELECT jp.photo_id, tkp.owner_id, je.journey_id + FROM journey_photos jp + JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN journey_entries je ON jp.entry_id = je.id WHERE jp.id = ? AND je.journey_id = ? `).get(photoId, row.journey_id) as any; @@ -71,14 +73,13 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo export function validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null { const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any; if (!row) return null; - // Check if this asset belongs to any photo in the shared journey const photo = db.prepare(` - SELECT jp.owner_id FROM journey_photos jp + SELECT tkp.owner_id FROM journey_photos jp + JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN journey_entries je ON jp.entry_id = je.id - WHERE jp.asset_id = ? AND je.journey_id = ? + WHERE tkp.asset_id = ? AND je.journey_id = ? `).get(assetId, row.journey_id) as any; if (!photo) { - // Fallback: get journey owner const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any; return journey ? { ownerId: journey.user_id } : null; } @@ -100,7 +101,10 @@ export function getPublicJourney(token: string) { `).all(row.journey_id) as any[]; const photos = db.prepare(` - SELECT jp.* FROM journey_photos jp + SELECT jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at, + tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height + FROM journey_photos jp + JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN journey_entries je ON jp.entry_id = je.id WHERE je.journey_id = ? ORDER BY jp.sort_order diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index fffe1592..4b75ff4f 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -129,15 +129,15 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number const journeyPhoto = db.prepare(` SELECT jp.entry_id, je.journey_id FROM journey_photos jp + JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN journey_entries je ON je.id = jp.entry_id - WHERE jp.asset_id = ? - AND jp.provider = ? - AND jp.owner_id = ? + WHERE tkp.asset_id = ? + AND tkp.provider = ? + AND tkp.owner_id = ? LIMIT 1 `).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined; if (!journeyPhoto) return false; - // Check if requesting user is the journey owner or a contributor const access = db.prepare(` SELECT 1 FROM journeys WHERE id = ? AND user_id = ? UNION ALL @@ -147,15 +147,16 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number return !!access; } - // Regular trip photos + // Regular trip photos — join through trek_photos const sharedAsset = db.prepare(` SELECT 1 - FROM trip_photos - WHERE user_id = ? - AND asset_id = ? - AND provider = ? - AND trip_id = ? - AND shared = 1 + FROM trip_photos tp + JOIN trek_photos tkp ON tkp.id = tp.photo_id + WHERE tp.user_id = ? + AND tkp.asset_id = ? + AND tkp.provider = ? + AND tp.trip_id = ? + AND tp.shared = 1 LIMIT 1 `).get(ownerUserId, assetId, provider, tripId); @@ -166,6 +167,52 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number } +// ── Unified photo access check (trek_photos based) ────────────────────── + +export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): boolean { + const photo = db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(trekPhotoId) as { id: number; provider: string; owner_id: number | null } | undefined; + if (!photo) return false; + + // Owner always has access + if (photo.owner_id === requestingUserId) return true; + + // Check trip_photos — is this photo shared in a trip the user has access to? + const tripAccess = db.prepare(` + SELECT 1 FROM trip_photos tp + WHERE tp.photo_id = ? + AND tp.shared = 1 + AND EXISTS ( + SELECT 1 FROM trip_members tm WHERE tm.trip_id = tp.trip_id AND tm.user_id = ? + UNION ALL + SELECT 1 FROM trips t WHERE t.id = tp.trip_id AND t.user_id = ? + ) + LIMIT 1 + `).get(trekPhotoId, requestingUserId, requestingUserId); + if (tripAccess) return true; + + // Check journey_photos — is this photo in a journey the user can access? + const journeyAccess = db.prepare(` + SELECT 1 FROM journey_photos jp + JOIN journey_entries je ON je.id = jp.entry_id + WHERE jp.photo_id = ? + AND EXISTS ( + SELECT 1 FROM journeys j WHERE j.id = je.journey_id AND j.user_id = ? + UNION ALL + SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = je.journey_id AND jc.user_id = ? + ) + LIMIT 1 + `).get(trekPhotoId, requestingUserId, requestingUserId); + if (journeyAccess) return true; + + // Local photos without owner (uploaded files) — check if user has journey access + if (photo.provider === 'local' && !photo.owner_id) { + return !!journeyAccess; + } + + return false; +} + + // ---------------------------------------------- //helpers for album link syncing diff --git a/server/src/services/memories/photoResolverService.ts b/server/src/services/memories/photoResolverService.ts new file mode 100644 index 00000000..c077774f --- /dev/null +++ b/server/src/services/memories/photoResolverService.ts @@ -0,0 +1,141 @@ +import { Response } from 'express'; +import path from 'path'; +import fs from 'fs'; +import { db } from '../../db/database'; +import type { TrekPhoto } from '../../types'; +import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichService'; +import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService'; +import type { ServiceResult, AssetInfo } from './helpersService'; +import { fail, success } from './helpersService'; + +// ── Lookup / Register ──────────────────────────────────────────────────── + +export function getOrCreateTrekPhoto( + provider: string, + assetId: string, + ownerId: number, +): number { + const existing = db.prepare( + 'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?' + ).get(provider, assetId, ownerId) as { id: number } | undefined; + if (existing) return existing.id; + + const res = db.prepare( + 'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)' + ).run(provider, assetId, ownerId); + return Number(res.lastInsertRowid); +} + +export function getOrCreateLocalTrekPhoto( + filePath: string, + thumbnailPath?: string | null, + width?: number | null, + height?: number | null, +): number { + const existing = db.prepare( + "SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?" + ).get(filePath) as { id: number } | undefined; + if (existing) return existing.id; + + const res = db.prepare( + 'INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height) VALUES (?, ?, ?, ?, ?)' + ).run('local', filePath, thumbnailPath || null, width || null, height || null); + return Number(res.lastInsertRowid); +} + +export function resolveTrekPhoto(photoId: number): TrekPhoto | null { + return db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(photoId) as TrekPhoto | undefined || null; +} + +// ── Streaming ──────────────────────────────────────────────────────────── + +export async function streamPhoto( + res: Response, + userId: number, + photoId: number, + kind: 'thumbnail' | 'original', +): Promise { + const photo = resolveTrekPhoto(photoId); + if (!photo) { + res.status(404).json({ error: 'Photo not found' }); + return; + } + + switch (photo.provider) { + case 'local': { + const filePath = path.join(__dirname, '../../../uploads', photo.file_path!); + if (!fs.existsSync(filePath)) { + res.status(404).json({ error: 'File not found' }); + return; + } + res.set('Cache-Control', 'public, max-age=86400'); + res.sendFile(filePath); + return; + } + case 'immich': { + await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!); + return; + } + case 'synologyphotos': { + await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind); + return; + } + default: + res.status(400).json({ error: `Unknown provider: ${photo.provider}` }); + } +} + +// ── Asset Info ──────────────────────────────────────────────────────────── + +export async function getPhotoInfo( + userId: number, + photoId: number, +): Promise> { + const photo = resolveTrekPhoto(photoId); + if (!photo) return fail('Photo not found', 404); + + switch (photo.provider) { + case 'local': { + return success({ + id: String(photo.id), + takenAt: photo.created_at, + city: null, + country: null, + width: photo.width, + height: photo.height, + fileName: photo.file_path?.split('/').pop() || null, + } as AssetInfo); + } + case 'immich': { + const result = await getImmichAssetInfo(userId, photo.asset_id!, photo.owner_id!); + if (result.error) return fail(result.error, result.status || 500); + return success(result.data as AssetInfo); + } + case 'synologyphotos': { + return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!); + } + default: + return fail(`Unknown provider: ${photo.provider}`, 400); + } +} + +// ── Update provider on existing trek_photo (for Immich upload sync) ───── + +export function setTrekPhotoProvider( + trekPhotoId: number, + provider: string, + assetId: string, + ownerId: number, +): void { + db.prepare( + 'UPDATE trek_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?' + ).run(provider, assetId, ownerId, trekPhotoId); +} + +// ── Delete local file for a trek_photo ────────────────────────────────── + +export function getTrekPhotoFilePath(photoId: number): string | null { + const photo = resolveTrekPhoto(photoId); + if (!photo || photo.provider !== 'local' || !photo.file_path) return null; + return path.join(__dirname, '../../../uploads', photo.file_path); +} diff --git a/server/src/services/memories/unifiedService.ts b/server/src/services/memories/unifiedService.ts index a836524b..ebdfba21 100644 --- a/server/src/services/memories/unifiedService.ts +++ b/server/src/services/memories/unifiedService.ts @@ -8,6 +8,7 @@ import { mapDbError, Selection, } from './helpersService'; +import { getOrCreateTrekPhoto } from './photoResolverService'; function _providers(): Array<{id: string; enabled: boolean}> { @@ -45,13 +46,14 @@ export function listTripPhotos(tripId: string, userId: number): ServiceResult '?').join(',')}) + AND tkp.provider IN (${enabledProviders.map(() => '?').join(',')}) ORDER BY tp.added_at ASC `).all(tripId, userId, ...enabledProviders); @@ -108,9 +110,10 @@ function _addTripPhoto(tripId: string, userId: number, provider: string, assetId return providerResult as ServiceResult; } try { + const photoId = getOrCreateTrekPhoto(provider, assetId, userId); const result = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)' - ).run(tripId, userId, assetId, provider, shared ? 1 : 0, albumLinkId || null); + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null); return success(result.changes > 0); } catch (error) { @@ -163,8 +166,7 @@ export async function addTripPhotos( export async function setTripPhotoSharing( tripId: string, userId: number, - provider: string, - assetId: string, + photoId: number, shared: boolean, sid?: string, ): Promise> { @@ -179,9 +181,8 @@ export async function setTripPhotoSharing( SET shared = ? WHERE trip_id = ? AND user_id = ? - AND asset_id = ? - AND provider = ? - `).run(shared ? 1 : 0, tripId, userId, assetId, provider); + AND photo_id = ? + `).run(shared ? 1 : 0, tripId, userId, photoId); await _notifySharedTripPhotos(tripId, userId, 1); broadcast(tripId, 'memories:updated', { userId }, sid); @@ -194,8 +195,7 @@ export async function setTripPhotoSharing( export function removeTripPhoto( tripId: string, userId: number, - provider: string, - assetId: string, + photoId: number, sid?: string, ): ServiceResult { const access = canAccessTrip(tripId, userId); @@ -208,9 +208,8 @@ export function removeTripPhoto( DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? - AND asset_id = ? - AND provider = ? - `).run(tripId, userId, assetId, provider); + AND photo_id = ? + `).run(tripId, userId, photoId); broadcast(tripId, 'memories:updated', { userId }, sid); diff --git a/server/src/types.ts b/server/src/types.ts index 477248be..83e9d51d 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -339,20 +339,34 @@ export interface JourneyEntry { updated_at: number; } -export interface JourneyPhoto { +export interface TrekPhoto { id: number; - entry_id: number; - provider: 'local' | 'immich' | 'synologyphotos'; + provider: string; asset_id?: string | null; owner_id?: number | null; file_path?: string | null; thumbnail_path?: string | null; - caption?: string | null; - sort_order: number; width?: number | null; height?: number | null; + created_at: string; +} + +export interface JourneyPhoto { + id: number; + entry_id: number; + photo_id: number; + caption?: string | null; + sort_order: number; shared: number; created_at: number; + // Joined from trek_photos for API responses + provider?: string; + asset_id?: string | null; + owner_id?: number | null; + file_path?: string | null; + thumbnail_path?: string | null; + width?: number | null; + height?: number | null; } export interface JourneyTrip {