feat: unified photo provider abstraction layer (#584)

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

- New trek_photos table (migration 98) with photo_id FK in
  trip_photos and journey_photos
- Unified /api/photos/:id/thumbnail|original|info endpoint
- photoResolverService for central resolution and streaming
- ProviderPicker: add "All Photos" tab, rename tabs, fix i18n
- Localize all hardcoded strings in JourneyDetailPage (14 langs)
- Fix date formatting to use browser locale instead of hardcoded 'en'
- Journey stats as styled tile cards
This commit is contained in:
Maurice
2026-04-13 20:08:31 +02:00
parent e629548a42
commit c0c59b6d80
34 changed files with 883 additions and 198 deletions
@@ -233,8 +233,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ HttpResponse.json({
photos: [ photos: [
{ asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' }, { photo_id: 1, 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: 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', () => http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ HttpResponse.json({
photos: [ photos: [
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' }, { photo_id: 10, 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: 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', () => http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ HttpResponse.json({
photos: [ photos: [
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' }, { photo_id: 10, 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: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
], ],
}) })
), ),
@@ -30,6 +30,7 @@ function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; p
// ── Types ─────────────────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────────────────
interface TripPhoto { interface TripPhoto {
photo_id: number
asset_id: string asset_id: string
provider: string provider: string
user_id: number user_id: number
@@ -105,19 +106,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
} }
function buildProviderAssetUrl(photo: TripPhoto, what: string): string { 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 { function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string {
const photo: TripPhoto = { // Picker photos are not yet saved — use provider-specific URL
asset_id: asset.id, return `${ADDON_PREFIX}/${asset.provider}/assets/${tripId}/${asset.id}/${userId}/${what}`
provider: asset.provider,
user_id: userId,
username: '',
shared: 0,
added_at: null
}
return buildProviderAssetUrl(photo, what)
} }
@@ -189,7 +183,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
} }
// Lightbox // Lightbox
const [lightboxId, setLightboxId] = useState<string | null>(null) const [lightboxId, setLightboxId] = useState<number | null>(null)
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null) const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
const [lightboxInfo, setLightboxInfo] = useState<any>(null) const [lightboxInfo, setLightboxInfo] = useState<any>(null)
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false) const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
@@ -357,11 +351,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try { try {
await apiClient.delete(buildUnifiedUrl('photos'), { await apiClient.delete(buildUnifiedUrl('photos'), {
data: { data: {
asset_id: photo.asset_id, photo_id: photo.photo_id,
provider: photo.provider,
}, },
}) })
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')) } } catch { toast.error(t('memories.error.removePhoto')) }
} }
@@ -371,11 +364,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try { try {
await apiClient.put(buildUnifiedUrl('photos', 'sharing'), { await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
shared, shared,
asset_id: photo.asset_id, photo_id: photo.photo_id,
provider: photo.provider,
}) })
setTripPhotos(prev => prev.map(p => 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')) } } catch { toast.error(t('memories.error.toggleSharing')) }
} }
@@ -839,10 +831,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{allVisible.map(photo => { {allVisible.map(photo => {
const isOwn = photo.user_id === currentUser?.id const isOwn = photo.user_id === currentUser?.id
return ( return (
<div key={`${photo.provider}:${photo.asset_id}`} className="group" <div key={photo.photo_id} className="group"
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }} style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
onClick={() => { onClick={() => {
setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) setLightboxId(photo.photo_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('') setLightboxOriginalSrc('')
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
@@ -961,7 +953,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setShowMobileInfo(false) 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 hasPrev = currentIdx > 0
const hasNext = currentIdx < allVisible.length - 1 const hasNext = currentIdx < allVisible.length - 1
const navigateTo = (idx: number) => { const navigateTo = (idx: number) => {
@@ -969,7 +961,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
if (!photo) return if (!photo) return
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('') setLightboxOriginalSrc('')
setLightboxId(photo.asset_id) setLightboxId(photo.photo_id)
setLightboxUserId(photo.user_id) setLightboxUserId(photo.user_id)
setLightboxInfo(null) setLightboxInfo(null)
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
+1 -2
View File
@@ -19,8 +19,7 @@ function abs(url: string | null | undefined): string {
} }
function pSrc(p: JourneyPhoto): string { function pSrc(p: JourneyPhoto): string {
if (p.provider === 'local') return abs(`/uploads/${p.file_path}`) return abs(`/api/photos/${p.photo_id}/original`)
return abs(`/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/original`)
} }
function fmtDate(d: string): string { function fmtDate(d: string): string {
@@ -286,7 +286,7 @@ export default function PlaceFormModal({
onChange={e => handleChange('description', e.target.value)} onChange={e => handleChange('description', e.target.value)}
rows={2} rows={2}
placeholder={t('places.formDescriptionPlaceholder')} placeholder={t('places.formDescriptionPlaceholder')}
className="form-input" style={{ resize: 'none' }} className="form-input" style={{ resize: 'vertical' }}
/> />
</div> </div>
+34
View File
@@ -1539,6 +1539,40 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'journey.entries.deleteTitle': 'حذف الإدخال', 'journey.entries.deleteTitle': 'حذف الإدخال',
'journey.photosUploaded': 'تم رفع {count} صورة', 'journey.photosUploaded': 'تم رفع {count} صورة',
'journey.photosAdded': 'تمت إضافة {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 Addon
'collab.tabs.chat': 'الدردشة', 'collab.tabs.chat': 'الدردشة',
+21
View File
@@ -1902,6 +1902,9 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Colaboradores', 'journey.detail.contributors': 'Colaboradores',
'journey.detail.readMore': 'Ler mais', 'journey.detail.readMore': 'Ler mais',
'journey.detail.prosCons': 'Prós e contras', '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.days': 'Dias',
'journey.stats.cities': 'Cidades', 'journey.stats.cities': 'Cidades',
'journey.stats.entries': 'Entradas', 'journey.stats.entries': 'Entradas',
@@ -1928,6 +1931,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Clima', 'journey.editor.weather': 'Clima',
'journey.editor.photoFirst': '1º', 'journey.editor.photoFirst': '1º',
'journey.editor.makeFirst': 'Tornar 1º', 'journey.editor.makeFirst': 'Tornar 1º',
'journey.editor.searching': 'Pesquisando...',
'journey.mood.amazing': 'Incrível', 'journey.mood.amazing': 'Incrível',
'journey.mood.good': 'Bom', 'journey.mood.good': 'Bom',
'journey.mood.neutral': 'Neutro', 'journey.mood.neutral': 'Neutro',
@@ -1972,6 +1976,13 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Link de compartilhamento removido', 'journey.share.linkDeleted': 'Link de compartilhamento removido',
'journey.share.deleteFailed': 'Não foi possível excluir', 'journey.share.deleteFailed': 'Não foi possível excluir',
'journey.share.updateFailed': 'Não foi possível atualizar', '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.title': 'Configurações da jornada',
'journey.settings.coverImage': 'Imagem de capa', 'journey.settings.coverImage': 'Imagem de capa',
'journey.settings.changeCover': 'Alterar capa', 'journey.settings.changeCover': 'Alterar capa',
@@ -2002,6 +2013,16 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Fim', 'journey.pdf.theEnd': 'Fim',
'journey.pdf.saveAsPdf': 'Salvar como PDF', 'journey.pdf.saveAsPdf': 'Salvar como PDF',
'journey.pdf.pages': 'páginas', '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.morning': 'Bom dia,',
'dashboard.greeting.afternoon': 'Boa tarde,', 'dashboard.greeting.afternoon': 'Boa tarde,',
'dashboard.greeting.evening': 'Boa noite,', 'dashboard.greeting.evening': 'Boa noite,',
+21
View File
@@ -1904,6 +1904,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Přispěvatelé', 'journey.detail.contributors': 'Přispěvatelé',
'journey.detail.readMore': 'Číst dále', 'journey.detail.readMore': 'Číst dále',
'journey.detail.prosCons': 'Klady a zápory', '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.days': 'Dny',
'journey.stats.cities': 'Města', 'journey.stats.cities': 'Města',
'journey.stats.entries': 'Záznamy', 'journey.stats.entries': 'Záznamy',
@@ -1930,6 +1933,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Počasí', 'journey.editor.weather': 'Počasí',
'journey.editor.photoFirst': '1.', 'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Nastavit jako 1.', 'journey.editor.makeFirst': 'Nastavit jako 1.',
'journey.editor.searching': 'Hledání...',
'journey.mood.amazing': 'Úžasný', 'journey.mood.amazing': 'Úžasný',
'journey.mood.good': 'Dobrý', 'journey.mood.good': 'Dobrý',
'journey.mood.neutral': 'Neutrální', 'journey.mood.neutral': 'Neutrální',
@@ -1974,6 +1978,13 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Odkaz ke sdílení smazán', 'journey.share.linkDeleted': 'Odkaz ke sdílení smazán',
'journey.share.deleteFailed': 'Smazání selhalo', 'journey.share.deleteFailed': 'Smazání selhalo',
'journey.share.updateFailed': 'Aktualizace selhala', '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.title': 'Nastavení cestovního deníku',
'journey.settings.coverImage': 'Titulní obrázek', 'journey.settings.coverImage': 'Titulní obrázek',
'journey.settings.changeCover': 'Změnit obal', 'journey.settings.changeCover': 'Změnit obal',
@@ -2004,6 +2015,16 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Konec', 'journey.pdf.theEnd': 'Konec',
'journey.pdf.saveAsPdf': 'Uložit jako PDF', 'journey.pdf.saveAsPdf': 'Uložit jako PDF',
'journey.pdf.pages': 'stran', '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.morning': 'Dobré ráno,',
'dashboard.greeting.afternoon': 'Dobré odpoledne,', 'dashboard.greeting.afternoon': 'Dobré odpoledne,',
'dashboard.greeting.evening': 'Dobrý večer,', 'dashboard.greeting.evening': 'Dobrý večer,',
+21
View File
@@ -1892,6 +1892,9 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Mitwirkende', 'journey.detail.contributors': 'Mitwirkende',
'journey.detail.readMore': 'Mehr lesen', 'journey.detail.readMore': 'Mehr lesen',
'journey.detail.prosCons': 'Pro & Contra', 'journey.detail.prosCons': 'Pro & Contra',
'journey.detail.photos': 'Fotos',
'journey.detail.day': 'Tag {number}',
'journey.detail.places': 'Orte',
'journey.stats.days': 'Tage', 'journey.stats.days': 'Tage',
'journey.stats.cities': 'Städte', 'journey.stats.cities': 'Städte',
'journey.stats.entries': 'Einträge', 'journey.stats.entries': 'Einträge',
@@ -1918,6 +1921,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Wetter', 'journey.editor.weather': 'Wetter',
'journey.editor.photoFirst': '1.', 'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Als 1. setzen', 'journey.editor.makeFirst': 'Als 1. setzen',
'journey.editor.searching': 'Suche...',
'journey.mood.amazing': 'Großartig', 'journey.mood.amazing': 'Großartig',
'journey.mood.good': 'Gut', 'journey.mood.good': 'Gut',
'journey.mood.neutral': 'Neutral', 'journey.mood.neutral': 'Neutral',
@@ -1962,6 +1966,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Link entfernt', 'journey.share.linkDeleted': 'Link entfernt',
'journey.share.deleteFailed': 'Entfernen fehlgeschlagen', 'journey.share.deleteFailed': 'Entfernen fehlgeschlagen',
'journey.share.updateFailed': 'Aktualisierung 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.title': 'Journey-Einstellungen',
'journey.settings.coverImage': 'Titelbild', 'journey.settings.coverImage': 'Titelbild',
'journey.settings.changeCover': 'Titelbild ändern', 'journey.settings.changeCover': 'Titelbild ändern',
@@ -1992,6 +2003,16 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Ende', 'journey.pdf.theEnd': 'Ende',
'journey.pdf.saveAsPdf': 'Als PDF speichern', 'journey.pdf.saveAsPdf': 'Als PDF speichern',
'journey.pdf.pages': 'Seiten', '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.morning': 'Guten Morgen,',
'dashboard.greeting.afternoon': 'Guten Tag,', 'dashboard.greeting.afternoon': 'Guten Tag,',
'dashboard.greeting.evening': 'Guten Abend,', 'dashboard.greeting.evening': 'Guten Abend,',
+21
View File
@@ -1895,6 +1895,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Contributors', 'journey.detail.contributors': 'Contributors',
'journey.detail.readMore': 'Read more', 'journey.detail.readMore': 'Read more',
'journey.detail.prosCons': 'Pros & Cons', 'journey.detail.prosCons': 'Pros & Cons',
'journey.detail.photos': 'photos',
'journey.detail.day': 'Day {number}',
'journey.detail.places': 'places',
// Journey Detail — Stats // Journey Detail — Stats
'journey.stats.days': 'Days', 'journey.stats.days': 'Days',
@@ -1929,6 +1932,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Weather', 'journey.editor.weather': 'Weather',
'journey.editor.photoFirst': '1st', 'journey.editor.photoFirst': '1st',
'journey.editor.makeFirst': 'Make 1st', 'journey.editor.makeFirst': 'Make 1st',
'journey.editor.searching': 'Searching...',
// Journey Entry — Moods // Journey Entry — Moods
'journey.mood.amazing': 'Amazing', 'journey.mood.amazing': 'Amazing',
@@ -1984,6 +1988,13 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.share.deleteFailed': 'Failed to delete', 'journey.share.deleteFailed': 'Failed to delete',
'journey.share.updateFailed': 'Failed to update', '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 Dialog
'journey.settings.title': 'Journey Settings', 'journey.settings.title': 'Journey Settings',
'journey.settings.coverImage': 'Cover Image', 'journey.settings.coverImage': 'Cover Image',
@@ -2019,6 +2030,16 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'The End', 'journey.pdf.theEnd': 'The End',
'journey.pdf.saveAsPdf': 'Save as PDF', 'journey.pdf.saveAsPdf': 'Save as PDF',
'journey.pdf.pages': 'pages', '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 Mobile
'dashboard.greeting.morning': 'Good morning,', 'dashboard.greeting.morning': 'Good morning,',
+21
View File
@@ -1906,6 +1906,9 @@ const es: Record<string, string> = {
'journey.detail.contributors': 'Colaboradores', 'journey.detail.contributors': 'Colaboradores',
'journey.detail.readMore': 'Leer más', 'journey.detail.readMore': 'Leer más',
'journey.detail.prosCons': 'Pros y contras', '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.days': 'Días',
'journey.stats.cities': 'Ciudades', 'journey.stats.cities': 'Ciudades',
'journey.stats.entries': 'Entradas', 'journey.stats.entries': 'Entradas',
@@ -1932,6 +1935,7 @@ const es: Record<string, string> = {
'journey.editor.weather': 'Clima', 'journey.editor.weather': 'Clima',
'journey.editor.photoFirst': '1º', 'journey.editor.photoFirst': '1º',
'journey.editor.makeFirst': 'Hacer 1º', 'journey.editor.makeFirst': 'Hacer 1º',
'journey.editor.searching': 'Buscando...',
'journey.mood.amazing': 'Increíble', 'journey.mood.amazing': 'Increíble',
'journey.mood.good': 'Bien', 'journey.mood.good': 'Bien',
'journey.mood.neutral': 'Neutral', 'journey.mood.neutral': 'Neutral',
@@ -1976,6 +1980,13 @@ const es: Record<string, string> = {
'journey.share.linkDeleted': 'Enlace para compartir eliminado', 'journey.share.linkDeleted': 'Enlace para compartir eliminado',
'journey.share.deleteFailed': 'No se pudo eliminar', 'journey.share.deleteFailed': 'No se pudo eliminar',
'journey.share.updateFailed': 'No se pudo actualizar', '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.title': 'Ajustes de la travesía',
'journey.settings.coverImage': 'Imagen de portada', 'journey.settings.coverImage': 'Imagen de portada',
'journey.settings.changeCover': 'Cambiar portada', 'journey.settings.changeCover': 'Cambiar portada',
@@ -2006,6 +2017,16 @@ const es: Record<string, string> = {
'journey.pdf.theEnd': 'Fin', 'journey.pdf.theEnd': 'Fin',
'journey.pdf.saveAsPdf': 'Guardar como PDF', 'journey.pdf.saveAsPdf': 'Guardar como PDF',
'journey.pdf.pages': 'páginas', '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.morning': 'Buenos días,',
'dashboard.greeting.afternoon': 'Buenas tardes,', 'dashboard.greeting.afternoon': 'Buenas tardes,',
'dashboard.greeting.evening': 'Buenas noches,', 'dashboard.greeting.evening': 'Buenas noches,',
+21
View File
@@ -1900,6 +1900,9 @@ const fr: Record<string, string> = {
'journey.detail.contributors': 'Contributeurs', 'journey.detail.contributors': 'Contributeurs',
'journey.detail.readMore': 'Lire la suite', 'journey.detail.readMore': 'Lire la suite',
'journey.detail.prosCons': 'Pour et contre', '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.days': 'Jours',
'journey.stats.cities': 'Villes', 'journey.stats.cities': 'Villes',
'journey.stats.entries': 'Entrées', 'journey.stats.entries': 'Entrées',
@@ -1926,6 +1929,7 @@ const fr: Record<string, string> = {
'journey.editor.weather': 'Météo', 'journey.editor.weather': 'Météo',
'journey.editor.photoFirst': '1er', 'journey.editor.photoFirst': '1er',
'journey.editor.makeFirst': 'Mettre en 1er', 'journey.editor.makeFirst': 'Mettre en 1er',
'journey.editor.searching': 'Recherche...',
'journey.mood.amazing': 'Incroyable', 'journey.mood.amazing': 'Incroyable',
'journey.mood.good': 'Bien', 'journey.mood.good': 'Bien',
'journey.mood.neutral': 'Neutre', 'journey.mood.neutral': 'Neutre',
@@ -1970,6 +1974,13 @@ const fr: Record<string, string> = {
'journey.share.linkDeleted': 'Lien de partage supprimé', 'journey.share.linkDeleted': 'Lien de partage supprimé',
'journey.share.deleteFailed': 'Échec de la suppression', 'journey.share.deleteFailed': 'Échec de la suppression',
'journey.share.updateFailed': 'Échec de la mise à jour', '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.title': 'Paramètres du journal',
'journey.settings.coverImage': 'Image de couverture', 'journey.settings.coverImage': 'Image de couverture',
'journey.settings.changeCover': 'Changer la couverture', 'journey.settings.changeCover': 'Changer la couverture',
@@ -2000,6 +2011,16 @@ const fr: Record<string, string> = {
'journey.pdf.theEnd': 'Fin', 'journey.pdf.theEnd': 'Fin',
'journey.pdf.saveAsPdf': 'Enregistrer en PDF', 'journey.pdf.saveAsPdf': 'Enregistrer en PDF',
'journey.pdf.pages': 'pages', '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.morning': 'Bonjour,',
'dashboard.greeting.afternoon': 'Bon après-midi,', 'dashboard.greeting.afternoon': 'Bon après-midi,',
'dashboard.greeting.evening': 'Bonsoir,', 'dashboard.greeting.evening': 'Bonsoir,',
+21
View File
@@ -1901,6 +1901,9 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Közreműködők', 'journey.detail.contributors': 'Közreműködők',
'journey.detail.readMore': 'Tovább olvasás', 'journey.detail.readMore': 'Tovább olvasás',
'journey.detail.prosCons': 'Előnyök és hátrányok', '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.days': 'Napok',
'journey.stats.cities': 'Városok', 'journey.stats.cities': 'Városok',
'journey.stats.entries': 'Bejegyzések', 'journey.stats.entries': 'Bejegyzések',
@@ -1927,6 +1930,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Időjárás', 'journey.editor.weather': 'Időjárás',
'journey.editor.photoFirst': '1.', 'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Legyen az 1.', 'journey.editor.makeFirst': 'Legyen az 1.',
'journey.editor.searching': 'Keresés...',
'journey.mood.amazing': 'Fantasztikus', 'journey.mood.amazing': 'Fantasztikus',
'journey.mood.good': 'Jó', 'journey.mood.good': 'Jó',
'journey.mood.neutral': 'Semleges', 'journey.mood.neutral': 'Semleges',
@@ -1971,6 +1975,13 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Megosztó link törölve', 'journey.share.linkDeleted': 'Megosztó link törölve',
'journey.share.deleteFailed': 'Nem sikerült törölni', 'journey.share.deleteFailed': 'Nem sikerült törölni',
'journey.share.updateFailed': 'Nem sikerült frissíteni', '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.title': 'Útinapló beállításai',
'journey.settings.coverImage': 'Borítókép', 'journey.settings.coverImage': 'Borítókép',
'journey.settings.changeCover': 'Borító módosítása', 'journey.settings.changeCover': 'Borító módosítása',
@@ -2001,6 +2012,16 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Vége', 'journey.pdf.theEnd': 'Vége',
'journey.pdf.saveAsPdf': 'Mentés PDF-ként', 'journey.pdf.saveAsPdf': 'Mentés PDF-ként',
'journey.pdf.pages': 'oldal', '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.morning': 'Jó reggelt,',
'dashboard.greeting.afternoon': 'Jó napot,', 'dashboard.greeting.afternoon': 'Jó napot,',
'dashboard.greeting.evening': 'Jó estét,', 'dashboard.greeting.evening': 'Jó estét,',
+21
View File
@@ -1901,6 +1901,9 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Contributori', 'journey.detail.contributors': 'Contributori',
'journey.detail.readMore': 'Leggi di più', 'journey.detail.readMore': 'Leggi di più',
'journey.detail.prosCons': 'Pro e contro', '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.days': 'Giorni',
'journey.stats.cities': 'Città', 'journey.stats.cities': 'Città',
'journey.stats.entries': 'Voci', 'journey.stats.entries': 'Voci',
@@ -1927,6 +1930,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Meteo', 'journey.editor.weather': 'Meteo',
'journey.editor.photoFirst': '1°', 'journey.editor.photoFirst': '1°',
'journey.editor.makeFirst': 'Metti 1°', 'journey.editor.makeFirst': 'Metti 1°',
'journey.editor.searching': 'Ricerca...',
'journey.mood.amazing': 'Fantastico', 'journey.mood.amazing': 'Fantastico',
'journey.mood.good': 'Buono', 'journey.mood.good': 'Buono',
'journey.mood.neutral': 'Neutro', 'journey.mood.neutral': 'Neutro',
@@ -1971,6 +1975,13 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Link di condivisione eliminato', 'journey.share.linkDeleted': 'Link di condivisione eliminato',
'journey.share.deleteFailed': 'Eliminazione fallita', 'journey.share.deleteFailed': 'Eliminazione fallita',
'journey.share.updateFailed': 'Aggiornamento fallito', '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.title': 'Impostazioni del diario',
'journey.settings.coverImage': 'Immagine di copertina', 'journey.settings.coverImage': 'Immagine di copertina',
'journey.settings.changeCover': 'Cambia copertina', 'journey.settings.changeCover': 'Cambia copertina',
@@ -2001,6 +2012,16 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Fine', 'journey.pdf.theEnd': 'Fine',
'journey.pdf.saveAsPdf': 'Salva come PDF', 'journey.pdf.saveAsPdf': 'Salva come PDF',
'journey.pdf.pages': 'pagine', '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.morning': 'Buongiorno,',
'dashboard.greeting.afternoon': 'Buon pomeriggio,', 'dashboard.greeting.afternoon': 'Buon pomeriggio,',
'dashboard.greeting.evening': 'Buonasera,', 'dashboard.greeting.evening': 'Buonasera,',
+21
View File
@@ -1900,6 +1900,9 @@ const nl: Record<string, string> = {
'journey.detail.contributors': 'Bijdragers', 'journey.detail.contributors': 'Bijdragers',
'journey.detail.readMore': 'Lees meer', 'journey.detail.readMore': 'Lees meer',
'journey.detail.prosCons': 'Voor- & nadelen', '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.days': 'Dagen',
'journey.stats.cities': 'Steden', 'journey.stats.cities': 'Steden',
'journey.stats.entries': 'Vermeldingen', 'journey.stats.entries': 'Vermeldingen',
@@ -1926,6 +1929,7 @@ const nl: Record<string, string> = {
'journey.editor.weather': 'Weer', 'journey.editor.weather': 'Weer',
'journey.editor.photoFirst': '1e', 'journey.editor.photoFirst': '1e',
'journey.editor.makeFirst': 'Maak 1e', 'journey.editor.makeFirst': 'Maak 1e',
'journey.editor.searching': 'Zoeken...',
'journey.mood.amazing': 'Fantastisch', 'journey.mood.amazing': 'Fantastisch',
'journey.mood.good': 'Goed', 'journey.mood.good': 'Goed',
'journey.mood.neutral': 'Neutraal', 'journey.mood.neutral': 'Neutraal',
@@ -1970,6 +1974,13 @@ const nl: Record<string, string> = {
'journey.share.linkDeleted': 'Deellink verwijderd', 'journey.share.linkDeleted': 'Deellink verwijderd',
'journey.share.deleteFailed': 'Verwijderen mislukt', 'journey.share.deleteFailed': 'Verwijderen mislukt',
'journey.share.updateFailed': 'Bijwerken 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.title': 'Reisverslaginstellingen',
'journey.settings.coverImage': 'Omslagfoto', 'journey.settings.coverImage': 'Omslagfoto',
'journey.settings.changeCover': 'Omslag wijzigen', 'journey.settings.changeCover': 'Omslag wijzigen',
@@ -2000,6 +2011,16 @@ const nl: Record<string, string> = {
'journey.pdf.theEnd': 'Einde', 'journey.pdf.theEnd': 'Einde',
'journey.pdf.saveAsPdf': 'Opslaan als PDF', 'journey.pdf.saveAsPdf': 'Opslaan als PDF',
'journey.pdf.pages': 'pagina\'s', '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.morning': 'Goedemorgen,',
'dashboard.greeting.afternoon': 'Goedemiddag,', 'dashboard.greeting.afternoon': 'Goedemiddag,',
'dashboard.greeting.evening': 'Goedenavond,', 'dashboard.greeting.evening': 'Goedenavond,',
+21
View File
@@ -1896,6 +1896,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Współtwórcy', 'journey.detail.contributors': 'Współtwórcy',
'journey.detail.readMore': 'Czytaj dalej', 'journey.detail.readMore': 'Czytaj dalej',
'journey.detail.prosCons': 'Zalety i wady', '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.days': 'Dni',
'journey.stats.cities': 'Miasta', 'journey.stats.cities': 'Miasta',
'journey.stats.entries': 'Wpisy', 'journey.stats.entries': 'Wpisy',
@@ -1922,6 +1925,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Pogoda', 'journey.editor.weather': 'Pogoda',
'journey.editor.photoFirst': '1.', 'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Ustaw jako 1.', 'journey.editor.makeFirst': 'Ustaw jako 1.',
'journey.editor.searching': 'Szukanie...',
'journey.mood.amazing': 'Niesamowity', 'journey.mood.amazing': 'Niesamowity',
'journey.mood.good': 'Dobry', 'journey.mood.good': 'Dobry',
'journey.mood.neutral': 'Neutralny', 'journey.mood.neutral': 'Neutralny',
@@ -1966,6 +1970,13 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Link udostępniania usunięty', 'journey.share.linkDeleted': 'Link udostępniania usunięty',
'journey.share.deleteFailed': 'Usunięcie nie powiodło się', 'journey.share.deleteFailed': 'Usunięcie nie powiodło się',
'journey.share.updateFailed': 'Aktualizacja nie powiodła 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.title': 'Ustawienia dziennika podróży',
'journey.settings.coverImage': 'Zdjęcie okładkowe', 'journey.settings.coverImage': 'Zdjęcie okładkowe',
'journey.settings.changeCover': 'Zmień okładkę', 'journey.settings.changeCover': 'Zmień okładkę',
@@ -1996,6 +2007,16 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Koniec', 'journey.pdf.theEnd': 'Koniec',
'journey.pdf.saveAsPdf': 'Zapisz jako PDF', 'journey.pdf.saveAsPdf': 'Zapisz jako PDF',
'journey.pdf.pages': 'stron', '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.morning': 'Dzień dobry,',
'dashboard.greeting.afternoon': 'Dzień dobry,', 'dashboard.greeting.afternoon': 'Dzień dobry,',
'dashboard.greeting.evening': 'Dobry wieczór,', 'dashboard.greeting.evening': 'Dobry wieczór,',
+21
View File
@@ -1900,6 +1900,9 @@ const ru: Record<string, string> = {
'journey.detail.contributors': 'Участники', 'journey.detail.contributors': 'Участники',
'journey.detail.readMore': 'Читать далее', 'journey.detail.readMore': 'Читать далее',
'journey.detail.prosCons': 'Плюсы и минусы', 'journey.detail.prosCons': 'Плюсы и минусы',
'journey.detail.photos': 'фото',
'journey.detail.day': 'День {number}',
'journey.detail.places': 'мест',
'journey.stats.days': 'Дней', 'journey.stats.days': 'Дней',
'journey.stats.cities': 'Городов', 'journey.stats.cities': 'Городов',
'journey.stats.entries': 'Записей', 'journey.stats.entries': 'Записей',
@@ -1926,6 +1929,7 @@ const ru: Record<string, string> = {
'journey.editor.weather': 'Погода', 'journey.editor.weather': 'Погода',
'journey.editor.photoFirst': '1-е', 'journey.editor.photoFirst': '1-е',
'journey.editor.makeFirst': 'Сделать 1-м', 'journey.editor.makeFirst': 'Сделать 1-м',
'journey.editor.searching': 'Поиск...',
'journey.mood.amazing': 'Потрясающе', 'journey.mood.amazing': 'Потрясающе',
'journey.mood.good': 'Хорошо', 'journey.mood.good': 'Хорошо',
'journey.mood.neutral': 'Нейтрально', 'journey.mood.neutral': 'Нейтрально',
@@ -1970,6 +1974,13 @@ const ru: Record<string, string> = {
'journey.share.linkDeleted': 'Ссылка удалена', 'journey.share.linkDeleted': 'Ссылка удалена',
'journey.share.deleteFailed': 'Не удалось удалить', 'journey.share.deleteFailed': 'Не удалось удалить',
'journey.share.updateFailed': 'Не удалось обновить', '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.title': 'Настройки путешествия',
'journey.settings.coverImage': 'Обложка', 'journey.settings.coverImage': 'Обложка',
'journey.settings.changeCover': 'Сменить обложку', 'journey.settings.changeCover': 'Сменить обложку',
@@ -2000,6 +2011,16 @@ const ru: Record<string, string> = {
'journey.pdf.theEnd': 'Конец', 'journey.pdf.theEnd': 'Конец',
'journey.pdf.saveAsPdf': 'Сохранить как PDF', 'journey.pdf.saveAsPdf': 'Сохранить как PDF',
'journey.pdf.pages': 'страниц', '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.morning': 'Доброе утро,',
'dashboard.greeting.afternoon': 'Добрый день,', 'dashboard.greeting.afternoon': 'Добрый день,',
'dashboard.greeting.evening': 'Добрый вечер,', 'dashboard.greeting.evening': 'Добрый вечер,',
+22 -1
View File
@@ -1900,6 +1900,9 @@ const zh: Record<string, string> = {
'journey.detail.contributors': '贡献者', 'journey.detail.contributors': '贡献者',
'journey.detail.readMore': '阅读更多', 'journey.detail.readMore': '阅读更多',
'journey.detail.prosCons': '优缺点', 'journey.detail.prosCons': '优缺点',
'journey.detail.photos': '照片',
'journey.detail.day': '第{number}天',
'journey.detail.places': '个地点',
'journey.stats.days': '天', 'journey.stats.days': '天',
'journey.stats.cities': '城市', 'journey.stats.cities': '城市',
'journey.stats.entries': '条目', 'journey.stats.entries': '条目',
@@ -1910,7 +1913,7 @@ const zh: Record<string, string> = {
'journey.synced.places': '个地点', 'journey.synced.places': '个地点',
'journey.synced.synced': '已同步', 'journey.synced.synced': '已同步',
'journey.editor.uploadPhotos': '上传照片', 'journey.editor.uploadPhotos': '上传照片',
'journey.editor.fromGallery': '从相册选择', 'journey.editor.fromGallery': '从相册',
'journey.editor.allPhotosAdded': '所有照片已添加', 'journey.editor.allPhotosAdded': '所有照片已添加',
'journey.editor.writeStory': '写下你的故事...', 'journey.editor.writeStory': '写下你的故事...',
'journey.editor.prosCons': '优缺点', 'journey.editor.prosCons': '优缺点',
@@ -1926,6 +1929,7 @@ const zh: Record<string, string> = {
'journey.editor.weather': '天气', 'journey.editor.weather': '天气',
'journey.editor.photoFirst': '第1张', 'journey.editor.photoFirst': '第1张',
'journey.editor.makeFirst': '设为第1张', 'journey.editor.makeFirst': '设为第1张',
'journey.editor.searching': '搜索中...',
'journey.mood.amazing': '太棒了', 'journey.mood.amazing': '太棒了',
'journey.mood.good': '不错', 'journey.mood.good': '不错',
'journey.mood.neutral': '一般', 'journey.mood.neutral': '一般',
@@ -1970,6 +1974,13 @@ const zh: Record<string, string> = {
'journey.share.linkDeleted': '分享链接已删除', 'journey.share.linkDeleted': '分享链接已删除',
'journey.share.deleteFailed': '删除失败', 'journey.share.deleteFailed': '删除失败',
'journey.share.updateFailed': '更新失败', '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.title': '旅程设置',
'journey.settings.coverImage': '封面图片', 'journey.settings.coverImage': '封面图片',
'journey.settings.changeCover': '更换封面', 'journey.settings.changeCover': '更换封面',
@@ -2000,6 +2011,16 @@ const zh: Record<string, string> = {
'journey.pdf.theEnd': '终', 'journey.pdf.theEnd': '终',
'journey.pdf.saveAsPdf': '保存为 PDF', 'journey.pdf.saveAsPdf': '保存为 PDF',
'journey.pdf.pages': '页', '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.morning': '早上好,',
'dashboard.greeting.afternoon': '下午好,', 'dashboard.greeting.afternoon': '下午好,',
'dashboard.greeting.evening': '晚上好,', 'dashboard.greeting.evening': '晚上好,',
+22 -1
View File
@@ -1861,6 +1861,9 @@ const zhTw: Record<string, string> = {
'journey.detail.contributors': '貢獻者', 'journey.detail.contributors': '貢獻者',
'journey.detail.readMore': '閱讀更多', 'journey.detail.readMore': '閱讀更多',
'journey.detail.prosCons': '優缺點', 'journey.detail.prosCons': '優缺點',
'journey.detail.photos': '照片',
'journey.detail.day': '第{number}天',
'journey.detail.places': '個地點',
'journey.stats.days': '天', 'journey.stats.days': '天',
'journey.stats.cities': '城市', 'journey.stats.cities': '城市',
'journey.stats.entries': '條目', 'journey.stats.entries': '條目',
@@ -1871,7 +1874,7 @@ const zhTw: Record<string, string> = {
'journey.synced.places': '個地點', 'journey.synced.places': '個地點',
'journey.synced.synced': '已同步', 'journey.synced.synced': '已同步',
'journey.editor.uploadPhotos': '上傳照片', 'journey.editor.uploadPhotos': '上傳照片',
'journey.editor.fromGallery': '從相簿選擇', 'journey.editor.fromGallery': '從相簿',
'journey.editor.allPhotosAdded': '所有照片已新增', 'journey.editor.allPhotosAdded': '所有照片已新增',
'journey.editor.writeStory': '寫下你的故事...', 'journey.editor.writeStory': '寫下你的故事...',
'journey.editor.prosCons': '優缺點', 'journey.editor.prosCons': '優缺點',
@@ -1887,6 +1890,7 @@ const zhTw: Record<string, string> = {
'journey.editor.weather': '天氣', 'journey.editor.weather': '天氣',
'journey.editor.photoFirst': '第1張', 'journey.editor.photoFirst': '第1張',
'journey.editor.makeFirst': '設為第1張', 'journey.editor.makeFirst': '設為第1張',
'journey.editor.searching': '搜尋中...',
'journey.mood.amazing': '太棒了', 'journey.mood.amazing': '太棒了',
'journey.mood.good': '不錯', 'journey.mood.good': '不錯',
'journey.mood.neutral': '一般', 'journey.mood.neutral': '一般',
@@ -1931,6 +1935,13 @@ const zhTw: Record<string, string> = {
'journey.share.linkDeleted': '分享連結已刪除', 'journey.share.linkDeleted': '分享連結已刪除',
'journey.share.deleteFailed': '刪除失敗', 'journey.share.deleteFailed': '刪除失敗',
'journey.share.updateFailed': '更新失敗', '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.title': '旅程設定',
'journey.settings.coverImage': '封面圖片', 'journey.settings.coverImage': '封面圖片',
'journey.settings.changeCover': '更換封面', 'journey.settings.changeCover': '更換封面',
@@ -1961,6 +1972,16 @@ const zhTw: Record<string, string> = {
'journey.pdf.theEnd': '終', 'journey.pdf.theEnd': '終',
'journey.pdf.saveAsPdf': '儲存為 PDF', 'journey.pdf.saveAsPdf': '儲存為 PDF',
'journey.pdf.pages': '頁', '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.morning': '早安,',
'dashboard.greeting.afternoon': '午安,', 'dashboard.greeting.afternoon': '午安,',
'dashboard.greeting.evening': '晚安,', 'dashboard.greeting.evening': '晚安,',
+17 -16
View File
@@ -113,6 +113,7 @@ const mockJourneyDetail = {
{ {
id: 100, id: 100,
entry_id: 10, entry_id: 10,
photo_id: 100,
provider: 'local', provider: 'local',
file_path: 'photos/test.jpg', file_path: 'photos/test.jpg',
asset_id: null, asset_id: null,
@@ -547,17 +548,17 @@ describe('JourneyDetailPage', () => {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [ 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, asset_id: null, owner_id: null, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now, 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, asset_id: null, owner_id: null, thumbnail_path: null,
caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now, 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, asset_id: null, owner_id: null, thumbnail_path: null,
caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now, caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now,
}, },
@@ -1991,7 +1992,7 @@ describe('JourneyDetailPage', () => {
const immichEntry = { const immichEntry = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [{ 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, asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}], }],
@@ -2025,7 +2026,7 @@ describe('JourneyDetailPage', () => {
const synologyEntry = { const synologyEntry = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [{ 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, asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}], }],
@@ -2617,11 +2618,11 @@ describe('JourneyDetailPage', () => {
const multiPhotoEntry = { const multiPhotoEntry = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [ 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: 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, 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: 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, 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: 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, 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: 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, 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: 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({ setupDefaultHandlers({
@@ -2645,8 +2646,8 @@ describe('JourneyDetailPage', () => {
const twoPhotoEntry = { const twoPhotoEntry = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [ 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: 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, 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: 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({ setupDefaultHandlers({
@@ -3344,7 +3345,7 @@ describe('JourneyDetailPage', () => {
}), }),
http.post('/api/journeys/entries/88/photos', () => { http.post('/api/journeys/entries/88/photos', () => {
uploadCalled = true; 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 = { const entryWithMultiPhotos = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [ 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: 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, 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: 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({ setupDefaultHandlers({
@@ -3564,7 +3565,7 @@ describe('JourneyDetailPage', () => {
}), }),
http.post('/api/journeys/entries/11/photos', () => { http.post('/api/journeys/entries/11/photos', () => {
uploadCalled = true; 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 }]);
}), }),
); );
+70 -67
View File
@@ -64,20 +64,14 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
function formatDate(d: string): { weekday: string; month: string; day: number } { function formatDate(d: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00') const date = new Date(d + 'T00:00:00')
return { return {
weekday: date.toLocaleDateString('en', { weekday: 'long' }), weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
month: date.toLocaleDateString('en', { month: 'long' }), month: date.toLocaleDateString(undefined, { month: 'long' }),
day: date.getDate(), day: date.getDate(),
} }
} }
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string { function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
if (p.provider === 'local') { return `/api/photos/${p.photo_id}/${size}`
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}`
} }
export default function JourneyDetailPage() { export default function JourneyDetailPage() {
@@ -195,7 +189,7 @@ export default function JourneyDetailPage() {
{/* Back link — desktop */} {/* Back link — desktop */}
<button onClick={() => navigate('/journey')} className="hidden md:inline-flex items-center gap-1.5 text-[12px] text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 mb-4 mx-0"> <button onClick={() => navigate('/journey')} className="hidden md:inline-flex items-center gap-1.5 text-[12px] text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 mb-4 mx-0">
<ArrowLeft size={14} /> <ArrowLeft size={14} />
Back to Journey {t('journey.detail.backToJourney')}
</button> </button>
{/* Hero card — full width */} {/* Hero card — full width */}
@@ -220,7 +214,7 @@ export default function JourneyDetailPage() {
)} )}
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium"> <div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
<RefreshCw size={11} /> <RefreshCw size={11} />
Synced with Trips {t('journey.detail.syncedWithTrips')}
</div> </div>
</div> </div>
{/* Mobile: back button on the left */} {/* Mobile: back button on the left */}
@@ -326,7 +320,7 @@ export default function JourneyDetailPage() {
{dayIdx + 1} {dayIdx + 1}
</div> </div>
<div> <div>
<h3 className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}, {fd.month} {fd.day}</h3> <h3 className="text-[14px] font-semibold text-zinc-900 dark:text-white">{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}</h3>
</div> </div>
</div> </div>
<div className="flex items-center gap-3 text-[11px] text-zinc-500"> <div className="flex items-center gap-3 text-[11px] text-zinc-500">
@@ -405,17 +399,17 @@ export default function JourneyDetailPage() {
{/* Stats panel */} {/* Stats panel */}
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4"> <div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
<div className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500 mb-3.5">{t('journey.detail.journeyStats')}</div> <div className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500 mb-3">{t('journey.detail.journeyStats')}</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-2">
{[ {[
{ value: `${sortedDates.length}`, label: t('journey.stats.days') }, { value: sortedDates.length, label: t('journey.stats.days') },
{ value: `${current.stats.entries}`, label: t('journey.stats.entries') }, { value: current.stats.entries, label: t('journey.stats.entries') },
{ value: `${current.stats.photos}`, label: t('journey.stats.photos') }, { value: current.stats.photos, label: t('journey.stats.photos') },
{ value: `${current.stats.cities}`, label: t('journey.stats.cities') }, { value: current.stats.cities, label: t('journey.stats.cities') },
].map(s => ( ].map(s => (
<div key={s.label}> <div key={s.label} className="rounded-lg bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-zinc-700/50 px-3 py-2.5">
<div className="text-[20px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white">{s.value}</div> <div className="text-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
<div className="text-[10px] uppercase tracking-[0.08em] text-zinc-500 font-medium">{s.label}</div> <div className="text-[9px] uppercase tracking-[0.1em] text-zinc-400 dark:text-zinc-500 font-semibold">{s.label}</div>
</div> </div>
))} ))}
</div> </div>
@@ -440,7 +434,7 @@ export default function JourneyDetailPage() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-xs font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div> <div className="text-xs font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
<div className="text-[10px] text-zinc-500 flex items-center gap-1.5"> <div className="text-[10px] text-zinc-500 flex items-center gap-1.5">
{trip.place_count || 0} places {trip.place_count || 0} {t('journey.detail.places')}
<span className="inline-flex items-center gap-0.5 px-1.5 py-px rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[9px] font-medium"><span className="w-1 h-1 rounded-full bg-emerald-500" />{t('journey.synced.synced')}</span> <span className="inline-flex items-center gap-0.5 px-1.5 py-px rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[9px] font-medium"><span className="w-1 h-1 rounded-full bg-emerald-500" />{t('journey.synced.synced')}</span>
</div> </div>
</div> </div>
@@ -674,7 +668,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
<div key={date}> <div key={date}>
{/* Day separator */} {/* Day separator */}
<div className="flex items-center gap-2.5 py-3"> <div className="flex items-center gap-2.5 py-3">
<span className="text-[10px] font-bold text-zinc-500 dark:text-zinc-400 tracking-[0.12em] uppercase">Day {dayIdx + 1}</span> <span className="text-[10px] font-bold text-zinc-500 dark:text-zinc-400 tracking-[0.12em] uppercase">{t('journey.detail.day', { number: dayIdx + 1 })}</span>
<span className="text-[10px] text-zinc-400 font-medium">{fd.month} {fd.day}</span> <span className="text-[10px] text-zinc-400 font-medium">{fd.month} {fd.day}</span>
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" /> <div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
</div> </div>
@@ -834,14 +828,16 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-2"> <div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<span className="text-[11px] text-zinc-500">{allPhotos.length} photos</span> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400">
<Camera size={10} /> {allPhotos.length} {t('journey.detail.photos')}
</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => galleryFileRef.current?.click()} onClick={() => galleryFileRef.current?.click()}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100" className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100"
> >
<Plus size={12} /> <Plus size={12} />
Upload {t('common.upload')}
</button> </button>
{availableProviders.map(p => ( {availableProviders.map(p => (
<button <button
@@ -873,7 +869,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))} onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))}
> >
<img <img
src={photo.provider !== 'local' ? photoUrl(photo, 'original') : photoUrl(photo)} src={photoUrl(photo, 'original')}
alt={photo.caption || ''} alt={photo.caption || ''}
className="w-full h-full object-cover transition-transform group-hover:scale-105" className="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy" loading="lazy"
@@ -901,7 +897,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
)} )}
<div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white"> <span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white">
{entry.entry_date} {new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
</span> </span>
</div> </div>
</div> </div>
@@ -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 }) { 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 ( return (
<img <img
src={src} src={src}
@@ -1368,7 +1364,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
onAdd: (assetIds: string[], entryId: number | null) => Promise<void> onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const [filter, setFilter] = useState<'trip' | 'custom' | 'album'>('trip') const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
const [photos, setPhotos] = useState<any[]>([]) const [photos, setPhotos] = useState<any[]>([])
const [albums, setAlbums] = useState<any[]>([]) const [albums, setAlbums] = useState<any[]>([])
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null) const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
@@ -1413,6 +1409,8 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
useEffect(() => { useEffect(() => {
if (filter === 'trip' && tripRange.from && tripRange.to) { if (filter === 'trip' && tripRange.from && tripRange.to) {
searchPhotos(tripRange.from, tripRange.to) searchPhotos(tripRange.from, tripRange.to)
} else if (filter === 'all') {
searchPhotos('', '')
} else if (filter === 'album' && albums.length === 0) { } else if (filter === 'album' && albums.length === 0) {
loadAlbums() loadAlbums()
} }
@@ -1432,7 +1430,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const targetLabel = targetEntryId const targetLabel = targetEntryId
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries') ? 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 ( return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}> <div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
@@ -1453,9 +1451,10 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1.5 mb-3"> <div className="flex gap-1.5 mb-3">
{[ {[
{ id: 'trip' as const, label: t('journey.trips.link') }, { id: 'trip' as const, label: t('journey.picker.tripPeriod') },
{ id: 'custom' as const, label: t('common.edit') }, { id: 'custom' as const, label: t('journey.picker.dateRange') },
{ id: 'album' as const, label: t('journey.share.gallery') }, { id: 'all' as const, label: t('journey.picker.allPhotos') },
{ id: 'album' as const, label: t('journey.picker.albums') },
].map(f => ( ].map(f => (
<button <button
key={f.id} key={f.id}
@@ -1479,11 +1478,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
<> <>
<Calendar size={13} className="text-zinc-400" /> <Calendar size={13} className="text-zinc-400" />
<span className="font-medium text-zinc-900 dark:text-white"> <span className="font-medium text-zinc-900 dark:text-white">
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' })} {new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span> </span>
<span className="text-zinc-400">&mdash;</span> <span className="text-zinc-400">&mdash;</span>
<span className="font-medium text-zinc-900 dark:text-white"> <span className="font-medium text-zinc-900 dark:text-white">
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' })} {new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</span> </span>
<span className="ml-1 text-zinc-400"> <span className="ml-1 text-zinc-400">
({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days) ({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days)
@@ -1502,7 +1501,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
<div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div> <div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
<button onClick={handleCustomSearch} <button onClick={handleCustomSearch}
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0"> className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0">
Search {t('journey.picker.search')}
</button> </button>
</div> </div>
)} )}
@@ -1522,16 +1521,16 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''} {a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
</button> </button>
))} ))}
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">No albums found</span>} {albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* Add-to */} {/* Add-to entry selector */}
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50"> <div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<div className="relative flex items-center gap-2"> <div className="relative flex items-center gap-2">
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">Add to</span> <span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
<button <button
onClick={() => setAddToOpen(!addToOpen)} onClick={() => setAddToOpen(!addToOpen)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800"
@@ -1552,10 +1551,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
}`} }`}
> >
<Camera size={12} /> <Camera size={12} />
Gallery {t('journey.picker.newGallery')}
</button> </button>
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" /> {entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
{entries.map(e => ( <div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
)}
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => (
<button <button
key={e.id} key={e.id}
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }} onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }}
@@ -1565,7 +1566,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700' : 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
}`} }`}
> >
{e.title || e.location_name || e.entry_date} {e.title || e.location_name || new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}
</button> </button>
))} ))}
</div> </div>
@@ -1638,19 +1639,20 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50"> <div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<span className="text-[12px] text-zinc-500"> <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
<strong className="text-zinc-900 dark:text-white">{selected.size}</strong> selected <span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"> <button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
Cancel {t('common.cancel')}
</button> </button>
<button <button
onClick={() => onAdd([...selected], targetEntryId)} onClick={() => onAdd([...selected], targetEntryId)}
disabled={selected.size === 0} disabled={selected.size === 0}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed" className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
> >
Add {selected.size > 0 ? `(${selected.size})` : ''} {t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
</button> </button>
</div> </div>
</div> </div>
@@ -1666,6 +1668,7 @@ function DatePicker({ value, onChange, tripDates }: {
onChange: (date: string) => void onChange: (date: string) => void
tripDates?: Set<string> tripDates?: Set<string>
}) { }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [viewMonth, setViewMonth] = useState(() => { const [viewMonth, setViewMonth] = useState(() => {
const d = value ? new Date(value + 'T00:00:00') : new Date() 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 daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate()
const firstDow = new Date(viewMonth.year, viewMonth.month, 1).getDay() 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 = () => { const prevMonth = () => {
setViewMonth(p => p.month === 0 ? { year: p.year - 1, month: 11 } : { ...p, month: p.month - 1 }) 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 i = 0; i < firstDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d) 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 ( return (
<div className="relative"> <div className="relative">
@@ -1719,8 +1722,8 @@ function DatePicker({ value, onChange, tripDates }: {
{/* Weekday headers */} {/* Weekday headers */}
<div className="grid grid-cols-7 mb-1"> <div className="grid grid-cols-7 mb-1">
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(d => ( {Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i).toLocaleDateString(undefined, { weekday: 'narrow' })).map((d, i) => (
<div key={d} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div> <div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
))} ))}
</div> </div>
@@ -1870,7 +1873,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
onClick={() => fileRef.current?.click()} 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" 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"
> >
<Plus size={13} /> Upload photos <Plus size={13} /> {t('journey.editor.uploadPhotos')}
</button> </button>
{galleryPhotos.length > 0 && ( {galleryPhotos.length > 0 && (
<button <button
@@ -1881,7 +1884,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
: 'border-dashed border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800' : 'border-dashed border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800'
}`} }`}
> >
<Image size={13} /> From Gallery <Image size={13} /> {t('journey.editor.fromGallery')}
</button> </button>
)} )}
</div> </div>
@@ -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" 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"
> >
<img src={photoUrl(gp)} alt="" className="w-full h-full object-cover" loading="lazy" onError={e => { if (gp.provider !== 'local') { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig } }} /> <img src={photoUrl(gp)} alt="" className="w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
</div> </div>
))} ))}
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && ( {galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
@@ -1920,7 +1923,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{photos.map((p, idx) => ( {photos.map((p, idx) => (
<div key={p.id} className={`w-20 h-20 rounded-lg overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}> <div key={p.id} className={`w-20 h-20 rounded-lg overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { if (p.provider !== 'local') { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig } }} /> <img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
{idx === 0 && photos.length > 1 && ( {idx === 0 && photos.length > 1 && (
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span> <span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
)} )}
@@ -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" 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')}
</button> </button>
)} )}
<button <button
@@ -2017,7 +2020,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
onClick={() => setPros([...pros, ''])} onClick={() => setPros([...pros, ''])}
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-green-200 dark:border-green-800/40 rounded-[10px] text-[12px] font-medium text-green-700 dark:text-green-400 hover:border-green-300 dark:hover:border-green-700 transition-colors" className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-green-200 dark:border-green-800/40 rounded-[10px] text-[12px] font-medium text-green-700 dark:text-green-400 hover:border-green-300 dark:hover:border-green-700 transition-colors"
> >
<Plus size={13} strokeWidth={2.5} /> Add another <Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
</button> </button>
</div> </div>
</div> </div>
@@ -2051,7 +2054,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
onClick={() => setCons([...cons, ''])} 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" 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"
> >
<Plus size={13} strokeWidth={2.5} /> Add another <Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
</button> </button>
</div> </div>
</div> </div>
@@ -2129,7 +2132,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
)} )}
{locationSearching && ( {locationSearching && (
<div className="absolute left-0 right-0 top-full mt-1 z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg px-3 py-3 text-center text-[12px] text-zinc-400"> <div className="absolute left-0 right-0 top-full mt-1 z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg px-3 py-3 text-center text-[12px] text-zinc-400">
Searching... {t('journey.editor.searching')}
</div> </div>
)} )}
</div> </div>
@@ -2268,7 +2271,7 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
disabled={adding === t.id} 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" 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')}
</button> </button>
</div> </div>
))} ))}
@@ -2376,19 +2379,19 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
{/* Role selector */} {/* Role selector */}
<div> <div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">Role</label> <label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.invite.role')}</label>
<div className="flex gap-2"> <div className="flex gap-2">
{(['viewer', 'editor'] as const).map(r => ( {(['viewer', 'editor'] as const).map(r => (
<button <button
key={r} key={r}
onClick={() => setRole(r)} onClick={() => setRole(r)}
className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all capitalize ${ className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all ${
role === r role === r
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white' ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
}`} }`}
> >
{r} {t(`journey.invite.${r}`)}
</button> </button>
))} ))}
</div> </div>
@@ -2397,14 +2400,14 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50"> <div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"> <button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
Cancel {t('common.cancel')}
</button> </button>
<button <button
onClick={handleInvite} onClick={handleInvite}
disabled={!selectedUserId || sending} disabled={!selectedUserId || sending}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed" className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
> >
{sending ? 'Inviting...' : 'Invite'} {sending ? t('journey.invite.inviting') : t('journey.invite.invite')}
</button> </button>
</div> </div>
</div> </div>
@@ -2471,7 +2474,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
onClick={createLink} 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" 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"
> >
<Link size={14} /> Create share link <Link size={14} /> {t('journey.share.createLink')}
</button> </button>
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -2483,7 +2486,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
onClick={copyLink} 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" 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')}
</button> </button>
</div> </div>
+4 -4
View File
@@ -97,7 +97,7 @@ const mockJourneyData = {
weather: 'cloudy', weather: 'cloudy',
pros_cons: null, pros_cons: null,
photos: [ 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, entry_time: null, location_name: null, location_lat: null, location_lng: null,
mood: null, weather: null, pros_cons: null, mood: null, weather: null, pros_cons: null,
photos: [ photos: [
{ id: 200, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A' }, { 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, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B' }, { 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, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C' }, { id: 202, entry_id: 20, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C' },
], ],
}, },
], ],
+3 -3
View File
@@ -26,7 +26,8 @@ interface PublicEntry {
interface PublicPhoto { interface PublicPhoto {
id: number id: number
entry_id: number entry_id: number
provider: string photo_id: number
provider?: string
asset_id?: string | null asset_id?: string | null
owner_id?: number | null owner_id?: number | null
file_path?: string | null file_path?: string | null
@@ -34,8 +35,7 @@ interface PublicPhoto {
} }
function photoUrl(p: PublicPhoto, shareToken: string): string { 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}/photos/${p.photo_id}/original`
return `/api/public/journey/${shareToken}/photo/${p.provider}/${p.asset_id}/${p.owner_id}/original`
} }
function formatDate(d: string): { weekday: string; month: string; day: number } { function formatDate(d: string): { weekday: string; month: string; day: number } {
+7 -5
View File
@@ -42,17 +42,19 @@ export interface JourneyEntry {
export interface JourneyPhoto { export interface JourneyPhoto {
id: number id: number
entry_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 asset_id?: string | null
owner_id?: number | null owner_id?: number | null
file_path?: string | null file_path?: string | null
thumbnail_path?: string | null thumbnail_path?: string | null
caption?: string | null
sort_order: number
width?: number | null width?: number | null
height?: number | null height?: number | null
shared: number
created_at: number
} }
export interface JourneyTrip { export interface JourneyTrip {
+2
View File
@@ -36,6 +36,7 @@ import { oauthPublicRouter, oauthApiRouter } from './routes/oauth';
import vacayRoutes from './routes/vacay'; import vacayRoutes from './routes/vacay';
import atlasRoutes from './routes/atlas'; import atlasRoutes from './routes/atlas';
import memoriesRoutes from './routes/memories/unified'; import memoriesRoutes from './routes/memories/unified';
import photoRoutes from './routes/photos';
import notificationRoutes from './routes/notifications'; import notificationRoutes from './routes/notifications';
import shareRoutes from './routes/share'; import shareRoutes from './routes/share';
import journeyRoutes from './routes/journey'; import journeyRoutes from './routes/journey';
@@ -265,6 +266,7 @@ export function createApp(): express.Application {
app.use('/api/journeys', journeyRoutes); app.use('/api/journeys', journeyRoutes);
app.use('/api/public/journey', journeyPublicRoutes); app.use('/api/public/journey', journeyPublicRoutes);
app.use('/api/integrations/memories', memoriesRoutes); app.use('/api/integrations/memories', memoriesRoutes);
app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes); app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes); app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes); app.use('/api/settings', settingsRoutes);
+109
View File
@@ -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 {} 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) { if (currentVersion < migrations.length) {
+12 -6
View File
@@ -1,5 +1,6 @@
import express, { Request, Response } from 'express'; 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 { streamImmichAsset } from '../services/memories/immichService';
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
@@ -12,16 +13,23 @@ router.get('/:token', (req: Request, res: Response) => {
res.json(data); 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) => { router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => {
const { token, provider, assetId, ownerId, kind } = req.params; const { token, provider, assetId, ownerId, kind } = req.params;
// Validate token and that this asset belongs to the shared journey
const valid = validateShareTokenForAsset(token, assetId); const valid = validateShareTokenForAsset(token, assetId);
if (!valid) return res.status(404).json({ error: 'Not found' }); if (!valid) return res.status(404).json({ error: 'Not found' });
if (provider === 'local') { if (provider === 'local') {
// Local file — assetId is the file_path
const filePath = path.join(__dirname, '../../uploads/journey', assetId); const filePath = path.join(__dirname, '../../uploads/journey', assetId);
const resolved = path.resolve(filePath); const resolved = path.resolve(filePath);
const uploadsDir = path.resolve(__dirname, '../../uploads'); 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); return res.sendFile(resolved);
} }
// Immich/Synology — proxy through
const effectiveOwnerId = valid.ownerId || Number(ownerId); const effectiveOwnerId = valid.ownerId || Number(ownerId);
if (provider === 'immich') { if (provider === 'immich') {
await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId); await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId);
} else { } else {
// Synology or other providers — try dynamic import
try { try {
const { streamSynologyAsset } = await import('../services/memories/synologyService'); const { streamSynologyAsset } = await import('../services/memories/synologyService');
await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original'); await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original');
+2 -3
View File
@@ -55,8 +55,7 @@ router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Re
const result = await setTripPhotoSharing( const result = await setTripPhotoSharing(
tripId, tripId,
authReq.user.id, authReq.user.id,
req.body?.provider, Number(req.body?.photo_id),
req.body?.asset_id,
req.body?.shared, req.body?.shared,
); );
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); 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) => { router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId } = req.params; 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 }); if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true }); res.json({ success: true });
}); });
+47
View File
@@ -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;
+39 -26
View File
@@ -1,11 +1,19 @@
import { db } from '../db/database'; import { db } from '../db/database';
import { broadcastToUser } from '../websocket'; import { broadcastToUser } from '../websocket';
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types'; import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider } from './memories/photoResolverService';
function ts(): number { function ts(): number {
return Date.now(); 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<string, unknown>, excludeUserId?: number) { function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeUserId?: number) {
const contributors = db.prepare( const contributors = db.prepare(
'SELECT user_id FROM journey_contributors WHERE journey_id = ?' 'SELECT user_id FROM journey_contributors WHERE journey_id = ?'
@@ -105,7 +113,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
).all(journeyId) as JourneyEntry[]; ).all(journeyId) as JourneyEntry[];
const photos = db.prepare( 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[]; ).all(journeyId) as JourneyPhoto[];
// group photos by entry // 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 // import trip_photos into journey when a trip is linked
function syncTripPhotos(journeyId: number, tripId: number) { function syncTripPhotos(journeyId: number, tripId: number) {
const tripPhotos = db.prepare( const tripPhotos = db.prepare(
'SELECT * FROM trip_photos WHERE trip_id = ?' 'SELECT tp.photo_id, tp.user_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
).all(tripId) as { id: number; trip_id: number; user_id: number; asset_id: string; provider: string; shared: number }[]; ).all(tripId) as { photo_id: number; user_id: number; shared: number }[];
if (!tripPhotos.length) return; if (!tripPhotos.length) return;
const now = ts(); const now = ts();
@@ -285,7 +293,6 @@ function syncTripPhotos(journeyId: number, tripId: number) {
`).get(journeyId, tripId) as { id: number } | undefined; `).get(journeyId, tripId) as { id: number } | undefined;
if (!photoEntry) { 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 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 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 }; 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) }; 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) { for (const tp of tripPhotos) {
const exists = db.prepare( const exists = db.prepare(
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?' 'SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?'
).get(photoEntry.id, tp.provider, tp.asset_id); ).get(photoEntry.id, tp.photo_id);
if (exists) continue; 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 }; 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(` db.prepare(`
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, shared, sort_order, created_at) INSERT INTO journey_photos (entry_id, photo_id, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(photoEntry.id, tp.provider, tp.asset_id, tp.user_id, tp.shared, (maxOrder?.m ?? -1) + 1, now); `).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[]; ).all(journeyId) as JourneyEntry[];
const photos = db.prepare( 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[]; ).all(journeyId) as JourneyPhoto[];
const photosByEntry: Record<number, JourneyPhoto[]> = {}; const photosByEntry: Record<number, JourneyPhoto[]> = {};
@@ -579,15 +586,16 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
if (!entry) return null; if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) 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 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 now = ts();
const res = db.prepare(` const res = db.prepare(`
INSERT INTO journey_photos (entry_id, provider, file_path, thumbnail_path, caption, sort_order, created_at) INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, 'local', ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(entryId, filePath, thumbnailPath || null, caption || null, (maxOrder?.m ?? -1) + 1, now); `).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 { 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 (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null; if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId);
// skip if already added // 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; 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 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 now = ts();
const res = db.prepare(` const res = db.prepare(`
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, caption, sort_order, created_at) INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(entryId, provider, assetId, userId, caption || null, (maxOrder?.m ?? -1) + 1, now); `).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 { 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 (!entry) return null;
if (!canEdit(entry.journey_id, userId)) 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) return null;
if (source.entry_id === entryId) return source; 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) { 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 { export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
const photo = db.prepare(` 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 JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ? WHERE jp.id = ?
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined; `).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); values.push(photoId);
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values); 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 { export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null {
const photo = db.prepare(` 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 JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ? WHERE jp.id = ?
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined; `).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
+10 -6
View File
@@ -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; const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null; if (!row) return null;
const photo = db.prepare(` 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 JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ? AND je.journey_id = ? WHERE jp.id = ? AND je.journey_id = ?
`).get(photoId, row.journey_id) as any; `).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 { 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; const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null; if (!row) return null;
// Check if this asset belongs to any photo in the shared journey
const photo = db.prepare(` 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 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; `).get(assetId, row.journey_id) as any;
if (!photo) { if (!photo) {
// Fallback: get journey owner
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any; const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
return journey ? { ownerId: journey.user_id } : null; return journey ? { ownerId: journey.user_id } : null;
} }
@@ -100,7 +101,10 @@ export function getPublicJourney(token: string) {
`).all(row.journey_id) as any[]; `).all(row.journey_id) as any[];
const photos = db.prepare(` 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 JOIN journey_entries je ON jp.entry_id = je.id
WHERE je.journey_id = ? WHERE je.journey_id = ?
ORDER BY jp.sort_order ORDER BY jp.sort_order
+58 -11
View File
@@ -129,15 +129,15 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
const journeyPhoto = db.prepare(` const journeyPhoto = db.prepare(`
SELECT jp.entry_id, je.journey_id SELECT jp.entry_id, je.journey_id
FROM journey_photos jp FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
JOIN journey_entries je ON je.id = jp.entry_id JOIN journey_entries je ON je.id = jp.entry_id
WHERE jp.asset_id = ? WHERE tkp.asset_id = ?
AND jp.provider = ? AND tkp.provider = ?
AND jp.owner_id = ? AND tkp.owner_id = ?
LIMIT 1 LIMIT 1
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined; `).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined;
if (!journeyPhoto) return false; if (!journeyPhoto) return false;
// Check if requesting user is the journey owner or a contributor
const access = db.prepare(` const access = db.prepare(`
SELECT 1 FROM journeys WHERE id = ? AND user_id = ? SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
UNION ALL UNION ALL
@@ -147,15 +147,16 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
return !!access; return !!access;
} }
// Regular trip photos // Regular trip photos — join through trek_photos
const sharedAsset = db.prepare(` const sharedAsset = db.prepare(`
SELECT 1 SELECT 1
FROM trip_photos FROM trip_photos tp
WHERE user_id = ? JOIN trek_photos tkp ON tkp.id = tp.photo_id
AND asset_id = ? WHERE tp.user_id = ?
AND provider = ? AND tkp.asset_id = ?
AND trip_id = ? AND tkp.provider = ?
AND shared = 1 AND tp.trip_id = ?
AND tp.shared = 1
LIMIT 1 LIMIT 1
`).get(ownerUserId, assetId, provider, tripId); `).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 //helpers for album link syncing
@@ -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<void> {
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<ServiceResult<AssetInfo>> {
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);
}
+13 -14
View File
@@ -8,6 +8,7 @@ import {
mapDbError, mapDbError,
Selection, Selection,
} from './helpersService'; } from './helpersService';
import { getOrCreateTrekPhoto } from './photoResolverService';
function _providers(): Array<{id: string; enabled: boolean}> { function _providers(): Array<{id: string; enabled: boolean}> {
@@ -45,13 +46,14 @@ export function listTripPhotos(tripId: string, userId: number): ServiceResult<an
} }
const photos = db.prepare(` const photos = db.prepare(`
SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at, SELECT tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at,
u.username, u.avatar u.username, u.avatar
FROM trip_photos tp FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
JOIN users u ON tp.user_id = u.id JOIN users u ON tp.user_id = u.id
WHERE tp.trip_id = ? WHERE tp.trip_id = ?
AND (tp.user_id = ? OR tp.shared = 1) AND (tp.user_id = ? OR tp.shared = 1)
AND tp.provider IN (${enabledProviders.map(() => '?').join(',')}) AND tkp.provider IN (${enabledProviders.map(() => '?').join(',')})
ORDER BY tp.added_at ASC ORDER BY tp.added_at ASC
`).all(tripId, userId, ...enabledProviders); `).all(tripId, userId, ...enabledProviders);
@@ -108,9 +110,10 @@ function _addTripPhoto(tripId: string, userId: number, provider: string, assetId
return providerResult as ServiceResult<boolean>; return providerResult as ServiceResult<boolean>;
} }
try { try {
const photoId = getOrCreateTrekPhoto(provider, assetId, userId);
const result = db.prepare( const result = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, assetId, provider, shared ? 1 : 0, albumLinkId || null); ).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
return success(result.changes > 0); return success(result.changes > 0);
} }
catch (error) { catch (error) {
@@ -163,8 +166,7 @@ export async function addTripPhotos(
export async function setTripPhotoSharing( export async function setTripPhotoSharing(
tripId: string, tripId: string,
userId: number, userId: number,
provider: string, photoId: number,
assetId: string,
shared: boolean, shared: boolean,
sid?: string, sid?: string,
): Promise<ServiceResult<true>> { ): Promise<ServiceResult<true>> {
@@ -179,9 +181,8 @@ export async function setTripPhotoSharing(
SET shared = ? SET shared = ?
WHERE trip_id = ? WHERE trip_id = ?
AND user_id = ? AND user_id = ?
AND asset_id = ? AND photo_id = ?
AND provider = ? `).run(shared ? 1 : 0, tripId, userId, photoId);
`).run(shared ? 1 : 0, tripId, userId, assetId, provider);
await _notifySharedTripPhotos(tripId, userId, 1); await _notifySharedTripPhotos(tripId, userId, 1);
broadcast(tripId, 'memories:updated', { userId }, sid); broadcast(tripId, 'memories:updated', { userId }, sid);
@@ -194,8 +195,7 @@ export async function setTripPhotoSharing(
export function removeTripPhoto( export function removeTripPhoto(
tripId: string, tripId: string,
userId: number, userId: number,
provider: string, photoId: number,
assetId: string,
sid?: string, sid?: string,
): ServiceResult<true> { ): ServiceResult<true> {
const access = canAccessTrip(tripId, userId); const access = canAccessTrip(tripId, userId);
@@ -208,9 +208,8 @@ export function removeTripPhoto(
DELETE FROM trip_photos DELETE FROM trip_photos
WHERE trip_id = ? WHERE trip_id = ?
AND user_id = ? AND user_id = ?
AND asset_id = ? AND photo_id = ?
AND provider = ? `).run(tripId, userId, photoId);
`).run(tripId, userId, assetId, provider);
broadcast(tripId, 'memories:updated', { userId }, sid); broadcast(tripId, 'memories:updated', { userId }, sid);
+19 -5
View File
@@ -339,20 +339,34 @@ export interface JourneyEntry {
updated_at: number; updated_at: number;
} }
export interface JourneyPhoto { export interface TrekPhoto {
id: number; id: number;
entry_id: number; provider: string;
provider: 'local' | 'immich' | 'synologyphotos';
asset_id?: string | null; asset_id?: string | null;
owner_id?: number | null; owner_id?: number | null;
file_path?: string | null; file_path?: string | null;
thumbnail_path?: string | null; thumbnail_path?: string | null;
caption?: string | null;
sort_order: number;
width?: number | null; width?: number | null;
height?: 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; shared: number;
created_at: 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 { export interface JourneyTrip {