feat: unified photo provider abstraction layer (#584)

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

- New trek_photos table (migration 98) with photo_id FK in
  trip_photos and journey_photos
- Unified /api/photos/:id/thumbnail|original|info endpoint
- photoResolverService for central resolution and streaming
- ProviderPicker: add "All Photos" tab, rename tabs, fix i18n
- Localize all hardcoded strings in JourneyDetailPage (14 langs)
- Fix date formatting to use browser locale instead of hardcoded 'en'
- Journey stats as styled tile cards
This commit is contained in:
Maurice
2026-04-13 20:08:31 +02:00
parent e629548a42
commit c0c59b6d80
34 changed files with 883 additions and 198 deletions
@@ -233,8 +233,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
{ asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
{ photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
{ photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
],
})
),
@@ -501,8 +501,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
@@ -676,8 +676,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
@@ -30,6 +30,7 @@ function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; p
// ── Types ───────────────────────────────────────────────────────────────────
interface TripPhoto {
photo_id: number
asset_id: string
provider: string
user_id: number
@@ -105,19 +106,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
}
function buildProviderAssetUrl(photo: TripPhoto, what: string): string {
return `${ADDON_PREFIX}/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/${what}`
return `/photos/${photo.photo_id}/${what}`
}
function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string {
const photo: TripPhoto = {
asset_id: asset.id,
provider: asset.provider,
user_id: userId,
username: '',
shared: 0,
added_at: null
}
return buildProviderAssetUrl(photo, what)
// Picker photos are not yet saved — use provider-specific URL
return `${ADDON_PREFIX}/${asset.provider}/assets/${tripId}/${asset.id}/${userId}/${what}`
}
@@ -189,7 +183,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
}
// Lightbox
const [lightboxId, setLightboxId] = useState<string | null>(null)
const [lightboxId, setLightboxId] = useState<number | null>(null)
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
@@ -357,11 +351,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try {
await apiClient.delete(buildUnifiedUrl('photos'), {
data: {
asset_id: photo.asset_id,
provider: photo.provider,
photo_id: photo.photo_id,
},
})
setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id)))
setTripPhotos(prev => prev.filter(p => p.photo_id !== photo.photo_id))
} catch { toast.error(t('memories.error.removePhoto')) }
}
@@ -371,11 +364,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try {
await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
shared,
asset_id: photo.asset_id,
provider: photo.provider,
photo_id: photo.photo_id,
})
setTripPhotos(prev => prev.map(p =>
p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p
p.photo_id === photo.photo_id ? { ...p, shared: shared ? 1 : 0 } : p
))
} catch { toast.error(t('memories.error.toggleSharing')) }
}
@@ -839,10 +831,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{allVisible.map(photo => {
const isOwn = photo.user_id === currentUser?.id
return (
<div key={`${photo.provider}:${photo.asset_id}`} className="group"
<div key={photo.photo_id} className="group"
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
onClick={() => {
setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
setLightboxId(photo.photo_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
@@ -961,7 +953,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setShowMobileInfo(false)
}
const currentIdx = allVisible.findIndex(p => p.asset_id === lightboxId)
const currentIdx = allVisible.findIndex(p => p.photo_id === lightboxId)
const hasPrev = currentIdx > 0
const hasNext = currentIdx < allVisible.length - 1
const navigateTo = (idx: number) => {
@@ -969,7 +961,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
if (!photo) return
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
setLightboxId(photo.asset_id)
setLightboxId(photo.photo_id)
setLightboxUserId(photo.user_id)
setLightboxInfo(null)
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
+1 -2
View File
@@ -19,8 +19,7 @@ function abs(url: string | null | undefined): string {
}
function pSrc(p: JourneyPhoto): string {
if (p.provider === 'local') return abs(`/uploads/${p.file_path}`)
return abs(`/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/original`)
return abs(`/api/photos/${p.photo_id}/original`)
}
function fmtDate(d: string): string {
@@ -286,7 +286,7 @@ export default function PlaceFormModal({
onChange={e => handleChange('description', e.target.value)}
rows={2}
placeholder={t('places.formDescriptionPlaceholder')}
className="form-input" style={{ resize: 'none' }}
className="form-input" style={{ resize: 'vertical' }}
/>
</div>
+34
View File
@@ -1539,6 +1539,40 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'journey.entries.deleteTitle': 'حذف الإدخال',
'journey.photosUploaded': 'تم رفع {count} صورة',
'journey.photosAdded': 'تمت إضافة {count} صورة',
'journey.picker.tripPeriod': 'فترة الرحلة',
'journey.picker.dateRange': 'نطاق التاريخ',
'journey.picker.allPhotos': 'كل الصور',
'journey.picker.albums': 'ألبومات',
'journey.picker.selected': 'محدد',
'journey.picker.addTo': 'إضافة إلى',
'journey.picker.newGallery': 'معرض جديد',
'journey.picker.noAlbums': 'لم يتم العثور على ألبومات',
'journey.picker.selectDate': 'اختر تاريخ',
'journey.picker.search': 'بحث',
// Journey Detail
'journey.detail.photos': 'صور',
'journey.detail.backToJourney': 'العودة للمجلة',
'journey.detail.day': 'اليوم {number}',
'journey.detail.places': 'أماكن',
// Journey — Invite
'journey.invite.role': 'الدور',
'journey.invite.viewer': 'مشاهد',
'journey.invite.editor': 'محرر',
'journey.invite.invite': 'دعوة',
'journey.invite.inviting': 'جارٍ الدعوة...',
// Journey Entry Editor
'journey.editor.uploadPhotos': 'رفع صور',
'journey.editor.fromGallery': 'من المعرض',
'journey.editor.addAnother': 'إضافة آخر',
'journey.editor.makeFirst': 'جعله الأول',
'journey.editor.searching': 'جارٍ البحث...',
// Journey — Share
'journey.share.copy': 'نسخ',
'journey.share.copied': 'تم النسخ!',
// Collab Addon
'collab.tabs.chat': 'الدردشة',
+21
View File
@@ -1902,6 +1902,9 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Colaboradores',
'journey.detail.readMore': 'Ler mais',
'journey.detail.prosCons': 'Prós e contras',
'journey.detail.photos': 'fotos',
'journey.detail.day': 'Dia {number}',
'journey.detail.places': 'lugares',
'journey.stats.days': 'Dias',
'journey.stats.cities': 'Cidades',
'journey.stats.entries': 'Entradas',
@@ -1928,6 +1931,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Clima',
'journey.editor.photoFirst': '1º',
'journey.editor.makeFirst': 'Tornar 1º',
'journey.editor.searching': 'Pesquisando...',
'journey.mood.amazing': 'Incrível',
'journey.mood.good': 'Bom',
'journey.mood.neutral': 'Neutro',
@@ -1972,6 +1976,13 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Link de compartilhamento removido',
'journey.share.deleteFailed': 'Não foi possível excluir',
'journey.share.updateFailed': 'Não foi possível atualizar',
// Journey — Invite
'journey.invite.role': 'Função',
'journey.invite.viewer': 'Visualizador',
'journey.invite.editor': 'Editor',
'journey.invite.invite': 'Convidar',
'journey.invite.inviting': 'Convidando...',
'journey.settings.title': 'Configurações da jornada',
'journey.settings.coverImage': 'Imagem de capa',
'journey.settings.changeCover': 'Alterar capa',
@@ -2002,6 +2013,16 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Fim',
'journey.pdf.saveAsPdf': 'Salvar como PDF',
'journey.pdf.pages': 'páginas',
'journey.picker.tripPeriod': 'Período da viagem',
'journey.picker.dateRange': 'Período',
'journey.picker.allPhotos': 'Todas as fotos',
'journey.picker.albums': 'Álbuns',
'journey.picker.selected': 'selecionados',
'journey.picker.addTo': 'Adicionar a',
'journey.picker.newGallery': 'Nova galeria',
'journey.picker.noAlbums': 'Nenhum álbum encontrado',
'journey.picker.selectDate': 'Selecionar data',
'journey.picker.search': 'Pesquisar',
'dashboard.greeting.morning': 'Bom dia,',
'dashboard.greeting.afternoon': 'Boa tarde,',
'dashboard.greeting.evening': 'Boa noite,',
+21
View File
@@ -1904,6 +1904,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Přispěvatelé',
'journey.detail.readMore': 'Číst dále',
'journey.detail.prosCons': 'Klady a zápory',
'journey.detail.photos': 'fotky',
'journey.detail.day': 'Den {number}',
'journey.detail.places': 'míst',
'journey.stats.days': 'Dny',
'journey.stats.cities': 'Města',
'journey.stats.entries': 'Záznamy',
@@ -1930,6 +1933,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Počasí',
'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Nastavit jako 1.',
'journey.editor.searching': 'Hledání...',
'journey.mood.amazing': 'Úžasný',
'journey.mood.good': 'Dobrý',
'journey.mood.neutral': 'Neutrální',
@@ -1974,6 +1978,13 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Odkaz ke sdílení smazán',
'journey.share.deleteFailed': 'Smazání selhalo',
'journey.share.updateFailed': 'Aktualizace selhala',
// Journey — Invite
'journey.invite.role': 'Role',
'journey.invite.viewer': 'Čtenář',
'journey.invite.editor': 'Editor',
'journey.invite.invite': 'Pozvat',
'journey.invite.inviting': 'Zveme...',
'journey.settings.title': 'Nastavení cestovního deníku',
'journey.settings.coverImage': 'Titulní obrázek',
'journey.settings.changeCover': 'Změnit obal',
@@ -2004,6 +2015,16 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Konec',
'journey.pdf.saveAsPdf': 'Uložit jako PDF',
'journey.pdf.pages': 'stran',
'journey.picker.tripPeriod': 'Období cesty',
'journey.picker.dateRange': 'Časové období',
'journey.picker.allPhotos': 'Všechny fotky',
'journey.picker.albums': 'Alba',
'journey.picker.selected': 'vybráno',
'journey.picker.addTo': 'Přidat do',
'journey.picker.newGallery': 'Nová galerie',
'journey.picker.noAlbums': 'Žádná alba nenalezena',
'journey.picker.selectDate': 'Vyberte datum',
'journey.picker.search': 'Hledat',
'dashboard.greeting.morning': 'Dobré ráno,',
'dashboard.greeting.afternoon': 'Dobré odpoledne,',
'dashboard.greeting.evening': 'Dobrý večer,',
+21
View File
@@ -1892,6 +1892,9 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Mitwirkende',
'journey.detail.readMore': 'Mehr lesen',
'journey.detail.prosCons': 'Pro & Contra',
'journey.detail.photos': 'Fotos',
'journey.detail.day': 'Tag {number}',
'journey.detail.places': 'Orte',
'journey.stats.days': 'Tage',
'journey.stats.cities': 'Städte',
'journey.stats.entries': 'Einträge',
@@ -1918,6 +1921,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Wetter',
'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Als 1. setzen',
'journey.editor.searching': 'Suche...',
'journey.mood.amazing': 'Großartig',
'journey.mood.good': 'Gut',
'journey.mood.neutral': 'Neutral',
@@ -1962,6 +1966,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Link entfernt',
'journey.share.deleteFailed': 'Entfernen fehlgeschlagen',
'journey.share.updateFailed': 'Aktualisierung fehlgeschlagen',
// Journey — Invite
'journey.invite.role': 'Rolle',
'journey.invite.viewer': 'Betrachter',
'journey.invite.editor': 'Bearbeiter',
'journey.invite.invite': 'Einladen',
'journey.invite.inviting': 'Wird eingeladen...',
'journey.settings.title': 'Journey-Einstellungen',
'journey.settings.coverImage': 'Titelbild',
'journey.settings.changeCover': 'Titelbild ändern',
@@ -1992,6 +2003,16 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Ende',
'journey.pdf.saveAsPdf': 'Als PDF speichern',
'journey.pdf.pages': 'Seiten',
'journey.picker.tripPeriod': 'Reisezeitraum',
'journey.picker.dateRange': 'Zeitraum',
'journey.picker.allPhotos': 'Alle Fotos',
'journey.picker.albums': 'Alben',
'journey.picker.selected': 'ausgewählt',
'journey.picker.addTo': 'Hinzufügen zu',
'journey.picker.newGallery': 'Neue Galerie',
'journey.picker.noAlbums': 'Keine Alben gefunden',
'journey.picker.selectDate': 'Datum wählen',
'journey.picker.search': 'Suchen',
'dashboard.greeting.morning': 'Guten Morgen,',
'dashboard.greeting.afternoon': 'Guten Tag,',
'dashboard.greeting.evening': 'Guten Abend,',
+21
View File
@@ -1895,6 +1895,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Contributors',
'journey.detail.readMore': 'Read more',
'journey.detail.prosCons': 'Pros & Cons',
'journey.detail.photos': 'photos',
'journey.detail.day': 'Day {number}',
'journey.detail.places': 'places',
// Journey Detail — Stats
'journey.stats.days': 'Days',
@@ -1929,6 +1932,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Weather',
'journey.editor.photoFirst': '1st',
'journey.editor.makeFirst': 'Make 1st',
'journey.editor.searching': 'Searching...',
// Journey Entry — Moods
'journey.mood.amazing': 'Amazing',
@@ -1984,6 +1988,13 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.share.deleteFailed': 'Failed to delete',
'journey.share.updateFailed': 'Failed to update',
// Journey — Invite
'journey.invite.role': 'Role',
'journey.invite.viewer': 'Viewer',
'journey.invite.editor': 'Editor',
'journey.invite.invite': 'Invite',
'journey.invite.inviting': 'Inviting...',
// Journey — Settings Dialog
'journey.settings.title': 'Journey Settings',
'journey.settings.coverImage': 'Cover Image',
@@ -2019,6 +2030,16 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'The End',
'journey.pdf.saveAsPdf': 'Save as PDF',
'journey.pdf.pages': 'pages',
'journey.picker.tripPeriod': 'Trip Period',
'journey.picker.dateRange': 'Date Range',
'journey.picker.allPhotos': 'All Photos',
'journey.picker.albums': 'Albums',
'journey.picker.selected': 'selected',
'journey.picker.addTo': 'Add to',
'journey.picker.newGallery': 'New Gallery',
'journey.picker.noAlbums': 'No albums found',
'journey.picker.selectDate': 'Select date',
'journey.picker.search': 'Search',
// Dashboard Mobile
'dashboard.greeting.morning': 'Good morning,',
+21
View File
@@ -1906,6 +1906,9 @@ const es: Record<string, string> = {
'journey.detail.contributors': 'Colaboradores',
'journey.detail.readMore': 'Leer más',
'journey.detail.prosCons': 'Pros y contras',
'journey.detail.photos': 'fotos',
'journey.detail.day': 'Día {number}',
'journey.detail.places': 'lugares',
'journey.stats.days': 'Días',
'journey.stats.cities': 'Ciudades',
'journey.stats.entries': 'Entradas',
@@ -1932,6 +1935,7 @@ const es: Record<string, string> = {
'journey.editor.weather': 'Clima',
'journey.editor.photoFirst': '1º',
'journey.editor.makeFirst': 'Hacer 1º',
'journey.editor.searching': 'Buscando...',
'journey.mood.amazing': 'Increíble',
'journey.mood.good': 'Bien',
'journey.mood.neutral': 'Neutral',
@@ -1976,6 +1980,13 @@ const es: Record<string, string> = {
'journey.share.linkDeleted': 'Enlace para compartir eliminado',
'journey.share.deleteFailed': 'No se pudo eliminar',
'journey.share.updateFailed': 'No se pudo actualizar',
// Journey — Invite
'journey.invite.role': 'Rol',
'journey.invite.viewer': 'Lector',
'journey.invite.editor': 'Editor',
'journey.invite.invite': 'Invitar',
'journey.invite.inviting': 'Invitando...',
'journey.settings.title': 'Ajustes de la travesía',
'journey.settings.coverImage': 'Imagen de portada',
'journey.settings.changeCover': 'Cambiar portada',
@@ -2006,6 +2017,16 @@ const es: Record<string, string> = {
'journey.pdf.theEnd': 'Fin',
'journey.pdf.saveAsPdf': 'Guardar como PDF',
'journey.pdf.pages': 'páginas',
'journey.picker.tripPeriod': 'Período del viaje',
'journey.picker.dateRange': 'Rango de fechas',
'journey.picker.allPhotos': 'Todas las fotos',
'journey.picker.albums': 'Álbumes',
'journey.picker.selected': 'seleccionados',
'journey.picker.addTo': 'Añadir a',
'journey.picker.newGallery': 'Nueva galería',
'journey.picker.noAlbums': 'No se encontraron álbumes',
'journey.picker.selectDate': 'Seleccionar fecha',
'journey.picker.search': 'Buscar',
'dashboard.greeting.morning': 'Buenos días,',
'dashboard.greeting.afternoon': 'Buenas tardes,',
'dashboard.greeting.evening': 'Buenas noches,',
+21
View File
@@ -1900,6 +1900,9 @@ const fr: Record<string, string> = {
'journey.detail.contributors': 'Contributeurs',
'journey.detail.readMore': 'Lire la suite',
'journey.detail.prosCons': 'Pour et contre',
'journey.detail.photos': 'photos',
'journey.detail.day': 'Jour {number}',
'journey.detail.places': 'lieux',
'journey.stats.days': 'Jours',
'journey.stats.cities': 'Villes',
'journey.stats.entries': 'Entrées',
@@ -1926,6 +1929,7 @@ const fr: Record<string, string> = {
'journey.editor.weather': 'Météo',
'journey.editor.photoFirst': '1er',
'journey.editor.makeFirst': 'Mettre en 1er',
'journey.editor.searching': 'Recherche...',
'journey.mood.amazing': 'Incroyable',
'journey.mood.good': 'Bien',
'journey.mood.neutral': 'Neutre',
@@ -1970,6 +1974,13 @@ const fr: Record<string, string> = {
'journey.share.linkDeleted': 'Lien de partage supprimé',
'journey.share.deleteFailed': 'Échec de la suppression',
'journey.share.updateFailed': 'Échec de la mise à jour',
// Journey — Invite
'journey.invite.role': 'Rôle',
'journey.invite.viewer': 'Lecteur',
'journey.invite.editor': 'Éditeur',
'journey.invite.invite': 'Inviter',
'journey.invite.inviting': 'Invitation...',
'journey.settings.title': 'Paramètres du journal',
'journey.settings.coverImage': 'Image de couverture',
'journey.settings.changeCover': 'Changer la couverture',
@@ -2000,6 +2011,16 @@ const fr: Record<string, string> = {
'journey.pdf.theEnd': 'Fin',
'journey.pdf.saveAsPdf': 'Enregistrer en PDF',
'journey.pdf.pages': 'pages',
'journey.picker.tripPeriod': 'Période du voyage',
'journey.picker.dateRange': 'Plage de dates',
'journey.picker.allPhotos': 'Toutes les photos',
'journey.picker.albums': 'Albums',
'journey.picker.selected': 'sélectionnés',
'journey.picker.addTo': 'Ajouter à',
'journey.picker.newGallery': 'Nouvelle galerie',
'journey.picker.noAlbums': 'Aucun album trouvé',
'journey.picker.selectDate': 'Sélectionner une date',
'journey.picker.search': 'Rechercher',
'dashboard.greeting.morning': 'Bonjour,',
'dashboard.greeting.afternoon': 'Bon après-midi,',
'dashboard.greeting.evening': 'Bonsoir,',
+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.readMore': 'Tovább olvasás',
'journey.detail.prosCons': 'Előnyök és hátrányok',
'journey.detail.photos': 'fotók',
'journey.detail.day': '{number}. nap',
'journey.detail.places': 'helyek',
'journey.stats.days': 'Napok',
'journey.stats.cities': 'Városok',
'journey.stats.entries': 'Bejegyzések',
@@ -1927,6 +1930,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Időjárás',
'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Legyen az 1.',
'journey.editor.searching': 'Keresés...',
'journey.mood.amazing': 'Fantasztikus',
'journey.mood.good': 'Jó',
'journey.mood.neutral': 'Semleges',
@@ -1971,6 +1975,13 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Megosztó link törölve',
'journey.share.deleteFailed': 'Nem sikerült törölni',
'journey.share.updateFailed': 'Nem sikerült frissíteni',
// Journey — Invite
'journey.invite.role': 'Szerepkör',
'journey.invite.viewer': 'Megtekintő',
'journey.invite.editor': 'Szerkesztő',
'journey.invite.invite': 'Meghívás',
'journey.invite.inviting': 'Meghívás...',
'journey.settings.title': 'Útinapló beállításai',
'journey.settings.coverImage': 'Borítókép',
'journey.settings.changeCover': 'Borító módosítása',
@@ -2001,6 +2012,16 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Vége',
'journey.pdf.saveAsPdf': 'Mentés PDF-ként',
'journey.pdf.pages': 'oldal',
'journey.picker.tripPeriod': 'Utazási időszak',
'journey.picker.dateRange': 'Időszak',
'journey.picker.allPhotos': 'Összes fotó',
'journey.picker.albums': 'Albumok',
'journey.picker.selected': 'kiválasztva',
'journey.picker.addTo': 'Hozzáadás',
'journey.picker.newGallery': 'Új galéria',
'journey.picker.noAlbums': 'Nem található album',
'journey.picker.selectDate': 'Dátum választása',
'journey.picker.search': 'Keresés',
'dashboard.greeting.morning': 'Jó reggelt,',
'dashboard.greeting.afternoon': 'Jó napot,',
'dashboard.greeting.evening': 'Jó estét,',
+21
View File
@@ -1901,6 +1901,9 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Contributori',
'journey.detail.readMore': 'Leggi di più',
'journey.detail.prosCons': 'Pro e contro',
'journey.detail.photos': 'foto',
'journey.detail.day': 'Giorno {number}',
'journey.detail.places': 'luoghi',
'journey.stats.days': 'Giorni',
'journey.stats.cities': 'Città',
'journey.stats.entries': 'Voci',
@@ -1927,6 +1930,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Meteo',
'journey.editor.photoFirst': '1°',
'journey.editor.makeFirst': 'Metti 1°',
'journey.editor.searching': 'Ricerca...',
'journey.mood.amazing': 'Fantastico',
'journey.mood.good': 'Buono',
'journey.mood.neutral': 'Neutro',
@@ -1971,6 +1975,13 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Link di condivisione eliminato',
'journey.share.deleteFailed': 'Eliminazione fallita',
'journey.share.updateFailed': 'Aggiornamento fallito',
// Journey — Invite
'journey.invite.role': 'Ruolo',
'journey.invite.viewer': 'Visualizzatore',
'journey.invite.editor': 'Editore',
'journey.invite.invite': 'Invita',
'journey.invite.inviting': 'Invito in corso...',
'journey.settings.title': 'Impostazioni del diario',
'journey.settings.coverImage': 'Immagine di copertina',
'journey.settings.changeCover': 'Cambia copertina',
@@ -2001,6 +2012,16 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Fine',
'journey.pdf.saveAsPdf': 'Salva come PDF',
'journey.pdf.pages': 'pagine',
'journey.picker.tripPeriod': 'Periodo del viaggio',
'journey.picker.dateRange': 'Intervallo di date',
'journey.picker.allPhotos': 'Tutte le foto',
'journey.picker.albums': 'Album',
'journey.picker.selected': 'selezionati',
'journey.picker.addTo': 'Aggiungi a',
'journey.picker.newGallery': 'Nuova galleria',
'journey.picker.noAlbums': 'Nessun album trovato',
'journey.picker.selectDate': 'Seleziona data',
'journey.picker.search': 'Cerca',
'dashboard.greeting.morning': 'Buongiorno,',
'dashboard.greeting.afternoon': 'Buon pomeriggio,',
'dashboard.greeting.evening': 'Buonasera,',
+21
View File
@@ -1900,6 +1900,9 @@ const nl: Record<string, string> = {
'journey.detail.contributors': 'Bijdragers',
'journey.detail.readMore': 'Lees meer',
'journey.detail.prosCons': 'Voor- & nadelen',
'journey.detail.photos': 'foto\'s',
'journey.detail.day': 'Dag {number}',
'journey.detail.places': 'plaatsen',
'journey.stats.days': 'Dagen',
'journey.stats.cities': 'Steden',
'journey.stats.entries': 'Vermeldingen',
@@ -1926,6 +1929,7 @@ const nl: Record<string, string> = {
'journey.editor.weather': 'Weer',
'journey.editor.photoFirst': '1e',
'journey.editor.makeFirst': 'Maak 1e',
'journey.editor.searching': 'Zoeken...',
'journey.mood.amazing': 'Fantastisch',
'journey.mood.good': 'Goed',
'journey.mood.neutral': 'Neutraal',
@@ -1970,6 +1974,13 @@ const nl: Record<string, string> = {
'journey.share.linkDeleted': 'Deellink verwijderd',
'journey.share.deleteFailed': 'Verwijderen mislukt',
'journey.share.updateFailed': 'Bijwerken mislukt',
// Journey — Invite
'journey.invite.role': 'Rol',
'journey.invite.viewer': 'Kijker',
'journey.invite.editor': 'Bewerker',
'journey.invite.invite': 'Uitnodigen',
'journey.invite.inviting': 'Uitnodigen...',
'journey.settings.title': 'Reisverslaginstellingen',
'journey.settings.coverImage': 'Omslagfoto',
'journey.settings.changeCover': 'Omslag wijzigen',
@@ -2000,6 +2011,16 @@ const nl: Record<string, string> = {
'journey.pdf.theEnd': 'Einde',
'journey.pdf.saveAsPdf': 'Opslaan als PDF',
'journey.pdf.pages': 'pagina\'s',
'journey.picker.tripPeriod': 'Reisperiode',
'journey.picker.dateRange': 'Datumbereik',
'journey.picker.allPhotos': 'Alle foto\'s',
'journey.picker.albums': 'Albums',
'journey.picker.selected': 'geselecteerd',
'journey.picker.addTo': 'Toevoegen aan',
'journey.picker.newGallery': 'Nieuwe galerij',
'journey.picker.noAlbums': 'Geen albums gevonden',
'journey.picker.selectDate': 'Selecteer datum',
'journey.picker.search': 'Zoeken',
'dashboard.greeting.morning': 'Goedemorgen,',
'dashboard.greeting.afternoon': 'Goedemiddag,',
'dashboard.greeting.evening': 'Goedenavond,',
+21
View File
@@ -1896,6 +1896,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Współtwórcy',
'journey.detail.readMore': 'Czytaj dalej',
'journey.detail.prosCons': 'Zalety i wady',
'journey.detail.photos': 'zdjęć',
'journey.detail.day': 'Dzień {number}',
'journey.detail.places': 'miejsc',
'journey.stats.days': 'Dni',
'journey.stats.cities': 'Miasta',
'journey.stats.entries': 'Wpisy',
@@ -1922,6 +1925,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Pogoda',
'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Ustaw jako 1.',
'journey.editor.searching': 'Szukanie...',
'journey.mood.amazing': 'Niesamowity',
'journey.mood.good': 'Dobry',
'journey.mood.neutral': 'Neutralny',
@@ -1966,6 +1970,13 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Link udostępniania usunięty',
'journey.share.deleteFailed': 'Usunięcie nie powiodło się',
'journey.share.updateFailed': 'Aktualizacja nie powiodła się',
// Journey — Invite
'journey.invite.role': 'Rola',
'journey.invite.viewer': 'Obserwator',
'journey.invite.editor': 'Redaktor',
'journey.invite.invite': 'Zaproś',
'journey.invite.inviting': 'Zapraszanie...',
'journey.settings.title': 'Ustawienia dziennika podróży',
'journey.settings.coverImage': 'Zdjęcie okładkowe',
'journey.settings.changeCover': 'Zmień okładkę',
@@ -1996,6 +2007,16 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Koniec',
'journey.pdf.saveAsPdf': 'Zapisz jako PDF',
'journey.pdf.pages': 'stron',
'journey.picker.tripPeriod': 'Okres podróży',
'journey.picker.dateRange': 'Zakres dat',
'journey.picker.allPhotos': 'Wszystkie zdjęcia',
'journey.picker.albums': 'Albumy',
'journey.picker.selected': 'wybranych',
'journey.picker.addTo': 'Dodaj do',
'journey.picker.newGallery': 'Nowa galeria',
'journey.picker.noAlbums': 'Nie znaleziono albumów',
'journey.picker.selectDate': 'Wybierz datę',
'journey.picker.search': 'Szukaj',
'dashboard.greeting.morning': 'Dzień dobry,',
'dashboard.greeting.afternoon': 'Dzień dobry,',
'dashboard.greeting.evening': 'Dobry wieczór,',
+21
View File
@@ -1900,6 +1900,9 @@ const ru: Record<string, string> = {
'journey.detail.contributors': 'Участники',
'journey.detail.readMore': 'Читать далее',
'journey.detail.prosCons': 'Плюсы и минусы',
'journey.detail.photos': 'фото',
'journey.detail.day': 'День {number}',
'journey.detail.places': 'мест',
'journey.stats.days': 'Дней',
'journey.stats.cities': 'Городов',
'journey.stats.entries': 'Записей',
@@ -1926,6 +1929,7 @@ const ru: Record<string, string> = {
'journey.editor.weather': 'Погода',
'journey.editor.photoFirst': '1-е',
'journey.editor.makeFirst': 'Сделать 1-м',
'journey.editor.searching': 'Поиск...',
'journey.mood.amazing': 'Потрясающе',
'journey.mood.good': 'Хорошо',
'journey.mood.neutral': 'Нейтрально',
@@ -1970,6 +1974,13 @@ const ru: Record<string, string> = {
'journey.share.linkDeleted': 'Ссылка удалена',
'journey.share.deleteFailed': 'Не удалось удалить',
'journey.share.updateFailed': 'Не удалось обновить',
// Journey — Invite
'journey.invite.role': 'Роль',
'journey.invite.viewer': 'Наблюдатель',
'journey.invite.editor': 'Редактор',
'journey.invite.invite': 'Пригласить',
'journey.invite.inviting': 'Приглашаем...',
'journey.settings.title': 'Настройки путешествия',
'journey.settings.coverImage': 'Обложка',
'journey.settings.changeCover': 'Сменить обложку',
@@ -2000,6 +2011,16 @@ const ru: Record<string, string> = {
'journey.pdf.theEnd': 'Конец',
'journey.pdf.saveAsPdf': 'Сохранить как PDF',
'journey.pdf.pages': 'страниц',
'journey.picker.tripPeriod': 'Период поездки',
'journey.picker.dateRange': 'Диапазон дат',
'journey.picker.allPhotos': 'Все фото',
'journey.picker.albums': 'Альбомы',
'journey.picker.selected': 'выбрано',
'journey.picker.addTo': 'Добавить в',
'journey.picker.newGallery': 'Новая галерея',
'journey.picker.noAlbums': 'Альбомы не найдены',
'journey.picker.selectDate': 'Выберите дату',
'journey.picker.search': 'Поиск',
'dashboard.greeting.morning': 'Доброе утро,',
'dashboard.greeting.afternoon': 'Добрый день,',
'dashboard.greeting.evening': 'Добрый вечер,',
+22 -1
View File
@@ -1900,6 +1900,9 @@ const zh: Record<string, string> = {
'journey.detail.contributors': '贡献者',
'journey.detail.readMore': '阅读更多',
'journey.detail.prosCons': '优缺点',
'journey.detail.photos': '照片',
'journey.detail.day': '第{number}天',
'journey.detail.places': '个地点',
'journey.stats.days': '天',
'journey.stats.cities': '城市',
'journey.stats.entries': '条目',
@@ -1910,7 +1913,7 @@ const zh: Record<string, string> = {
'journey.synced.places': '个地点',
'journey.synced.synced': '已同步',
'journey.editor.uploadPhotos': '上传照片',
'journey.editor.fromGallery': '从相册选择',
'journey.editor.fromGallery': '从相册',
'journey.editor.allPhotosAdded': '所有照片已添加',
'journey.editor.writeStory': '写下你的故事...',
'journey.editor.prosCons': '优缺点',
@@ -1926,6 +1929,7 @@ const zh: Record<string, string> = {
'journey.editor.weather': '天气',
'journey.editor.photoFirst': '第1张',
'journey.editor.makeFirst': '设为第1张',
'journey.editor.searching': '搜索中...',
'journey.mood.amazing': '太棒了',
'journey.mood.good': '不错',
'journey.mood.neutral': '一般',
@@ -1970,6 +1974,13 @@ const zh: Record<string, string> = {
'journey.share.linkDeleted': '分享链接已删除',
'journey.share.deleteFailed': '删除失败',
'journey.share.updateFailed': '更新失败',
// Journey — Invite
'journey.invite.role': '角色',
'journey.invite.viewer': '查看者',
'journey.invite.editor': '编辑者',
'journey.invite.invite': '邀请',
'journey.invite.inviting': '邀请中...',
'journey.settings.title': '旅程设置',
'journey.settings.coverImage': '封面图片',
'journey.settings.changeCover': '更换封面',
@@ -2000,6 +2011,16 @@ const zh: Record<string, string> = {
'journey.pdf.theEnd': '终',
'journey.pdf.saveAsPdf': '保存为 PDF',
'journey.pdf.pages': '页',
'journey.picker.tripPeriod': '旅行时间段',
'journey.picker.dateRange': '日期范围',
'journey.picker.allPhotos': '所有照片',
'journey.picker.albums': '相册',
'journey.picker.selected': '已选择',
'journey.picker.addTo': '添加到',
'journey.picker.newGallery': '新相册',
'journey.picker.noAlbums': '未找到相册',
'journey.picker.selectDate': '选择日期',
'journey.picker.search': '搜索',
'dashboard.greeting.morning': '早上好,',
'dashboard.greeting.afternoon': '下午好,',
'dashboard.greeting.evening': '晚上好,',
+22 -1
View File
@@ -1861,6 +1861,9 @@ const zhTw: Record<string, string> = {
'journey.detail.contributors': '貢獻者',
'journey.detail.readMore': '閱讀更多',
'journey.detail.prosCons': '優缺點',
'journey.detail.photos': '照片',
'journey.detail.day': '第{number}天',
'journey.detail.places': '個地點',
'journey.stats.days': '天',
'journey.stats.cities': '城市',
'journey.stats.entries': '條目',
@@ -1871,7 +1874,7 @@ const zhTw: Record<string, string> = {
'journey.synced.places': '個地點',
'journey.synced.synced': '已同步',
'journey.editor.uploadPhotos': '上傳照片',
'journey.editor.fromGallery': '從相簿選擇',
'journey.editor.fromGallery': '從相簿',
'journey.editor.allPhotosAdded': '所有照片已新增',
'journey.editor.writeStory': '寫下你的故事...',
'journey.editor.prosCons': '優缺點',
@@ -1887,6 +1890,7 @@ const zhTw: Record<string, string> = {
'journey.editor.weather': '天氣',
'journey.editor.photoFirst': '第1張',
'journey.editor.makeFirst': '設為第1張',
'journey.editor.searching': '搜尋中...',
'journey.mood.amazing': '太棒了',
'journey.mood.good': '不錯',
'journey.mood.neutral': '一般',
@@ -1931,6 +1935,13 @@ const zhTw: Record<string, string> = {
'journey.share.linkDeleted': '分享連結已刪除',
'journey.share.deleteFailed': '刪除失敗',
'journey.share.updateFailed': '更新失敗',
// Journey — Invite
'journey.invite.role': '角色',
'journey.invite.viewer': '檢視者',
'journey.invite.editor': '編輯者',
'journey.invite.invite': '邀請',
'journey.invite.inviting': '邀請中...',
'journey.settings.title': '旅程設定',
'journey.settings.coverImage': '封面圖片',
'journey.settings.changeCover': '更換封面',
@@ -1961,6 +1972,16 @@ const zhTw: Record<string, string> = {
'journey.pdf.theEnd': '終',
'journey.pdf.saveAsPdf': '儲存為 PDF',
'journey.pdf.pages': '頁',
'journey.picker.tripPeriod': '旅行期間',
'journey.picker.dateRange': '日期範圍',
'journey.picker.allPhotos': '所有照片',
'journey.picker.albums': '相簿',
'journey.picker.selected': '已選擇',
'journey.picker.addTo': '新增至',
'journey.picker.newGallery': '新相簿',
'journey.picker.noAlbums': '未找到相簿',
'journey.picker.selectDate': '選擇日期',
'journey.picker.search': '搜尋',
'dashboard.greeting.morning': '早安,',
'dashboard.greeting.afternoon': '午安,',
'dashboard.greeting.evening': '晚安,',
+17 -16
View File
@@ -113,6 +113,7 @@ const mockJourneyDetail = {
{
id: 100,
entry_id: 10,
photo_id: 100,
provider: 'local',
file_path: 'photos/test.jpg',
asset_id: null,
@@ -547,17 +548,17 @@ describe('JourneyDetailPage', () => {
...mockJourneyDetail.entries[0],
photos: [
{
id: 100, entry_id: 10, provider: 'local' as const, file_path: 'photos/a.jpg',
id: 100, entry_id: 10, photo_id: 100, provider: 'local' as const, file_path: 'photos/a.jpg',
asset_id: null, owner_id: null, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
},
{
id: 101, entry_id: 10, provider: 'local' as const, file_path: 'photos/b.jpg',
id: 101, entry_id: 10, photo_id: 101, provider: 'local' as const, file_path: 'photos/b.jpg',
asset_id: null, owner_id: null, thumbnail_path: null,
caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now,
},
{
id: 102, entry_id: 10, provider: 'local' as const, file_path: 'photos/c.jpg',
id: 102, entry_id: 10, photo_id: 102, provider: 'local' as const, file_path: 'photos/c.jpg',
asset_id: null, owner_id: null, thumbnail_path: null,
caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now,
},
@@ -1991,7 +1992,7 @@ describe('JourneyDetailPage', () => {
const immichEntry = {
...mockJourneyDetail.entries[0],
photos: [{
id: 200, entry_id: 10, provider: 'immich', file_path: null,
id: 200, entry_id: 10, photo_id: 200, provider: 'immich', file_path: null,
asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
@@ -2025,7 +2026,7 @@ describe('JourneyDetailPage', () => {
const synologyEntry = {
...mockJourneyDetail.entries[0],
photos: [{
id: 201, entry_id: 10, provider: 'synology', file_path: null,
id: 201, entry_id: 10, photo_id: 201, provider: 'synology', file_path: null,
asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
@@ -2617,11 +2618,11 @@ describe('JourneyDetailPage', () => {
const multiPhotoEntry = {
...mockJourneyDetail.entries[0],
photos: [
{ id: 100, entry_id: 10, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
{ id: 101, entry_id: 10, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
{ id: 102, entry_id: 10, provider: 'local', file_path: 'photos/c.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now },
{ id: 103, entry_id: 10, provider: 'local', file_path: 'photos/d.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 3, width: 800, height: 600, shared: 1, created_at: now },
{ id: 104, entry_id: 10, provider: 'local', file_path: 'photos/e.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 4, width: 800, height: 600, shared: 1, created_at: now },
{ id: 100, entry_id: 10, photo_id: 100, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
{ id: 101, entry_id: 10, photo_id: 101, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
{ id: 102, entry_id: 10, photo_id: 102, provider: 'local', file_path: 'photos/c.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now },
{ id: 103, entry_id: 10, photo_id: 103, provider: 'local', file_path: 'photos/d.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 3, width: 800, height: 600, shared: 1, created_at: now },
{ id: 104, entry_id: 10, photo_id: 104, provider: 'local', file_path: 'photos/e.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 4, width: 800, height: 600, shared: 1, created_at: now },
],
};
setupDefaultHandlers({
@@ -2645,8 +2646,8 @@ describe('JourneyDetailPage', () => {
const twoPhotoEntry = {
...mockJourneyDetail.entries[0],
photos: [
{ id: 100, entry_id: 10, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
{ id: 101, entry_id: 10, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
{ id: 100, entry_id: 10, photo_id: 100, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
{ id: 101, entry_id: 10, photo_id: 101, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
],
};
setupDefaultHandlers({
@@ -3344,7 +3345,7 @@ describe('JourneyDetailPage', () => {
}),
http.post('/api/journeys/entries/88/photos', () => {
uploadCalled = true;
return HttpResponse.json([{ id: 999, entry_id: 88, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]);
return HttpResponse.json([{ id: 999, entry_id: 88, photo_id: 999, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]);
}),
);
@@ -3510,8 +3511,8 @@ describe('JourneyDetailPage', () => {
const entryWithMultiPhotos = {
...mockJourneyDetail.entries[0],
photos: [
{ id: 100, entry_id: 10, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
{ id: 101, entry_id: 10, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
{ id: 100, entry_id: 10, photo_id: 100, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
{ id: 101, entry_id: 10, photo_id: 101, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
],
};
setupDefaultHandlers({
@@ -3564,7 +3565,7 @@ describe('JourneyDetailPage', () => {
}),
http.post('/api/journeys/entries/11/photos', () => {
uploadCalled = true;
return HttpResponse.json([{ id: 300, entry_id: 11, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]);
return HttpResponse.json([{ id: 300, entry_id: 11, photo_id: 300, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]);
}),
);
+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 } {
const date = new Date(d + 'T00:00:00')
return {
weekday: date.toLocaleDateString('en', { weekday: 'long' }),
month: date.toLocaleDateString('en', { month: 'long' }),
weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
month: date.toLocaleDateString(undefined, { month: 'long' }),
day: date.getDate(),
}
}
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
if (p.provider === 'local') {
return `/uploads/${p.file_path}`
}
// Immich / Synology — stream through the existing memories proxy
// tripId=0 is a placeholder, the proxy uses owner_id to find credentials
const kind = size === 'thumbnail' ? 'thumbnail' : 'original'
return `/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/${kind}`
return `/api/photos/${p.photo_id}/${size}`
}
export default function JourneyDetailPage() {
@@ -195,7 +189,7 @@ export default function JourneyDetailPage() {
{/* Back link — desktop */}
<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} />
Back to Journey
{t('journey.detail.backToJourney')}
</button>
{/* 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">
<RefreshCw size={11} />
Synced with Trips
{t('journey.detail.syncedWithTrips')}
</div>
</div>
{/* Mobile: back button on the left */}
@@ -326,7 +320,7 @@ export default function JourneyDetailPage() {
{dayIdx + 1}
</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 className="flex items-center gap-3 text-[11px] text-zinc-500">
@@ -405,17 +399,17 @@ export default function JourneyDetailPage() {
{/* Stats panel */}
<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="grid grid-cols-2 gap-3">
<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-2">
{[
{ value: `${sortedDates.length}`, label: t('journey.stats.days') },
{ value: `${current.stats.entries}`, label: t('journey.stats.entries') },
{ value: `${current.stats.photos}`, label: t('journey.stats.photos') },
{ value: `${current.stats.cities}`, label: t('journey.stats.cities') },
{ value: sortedDates.length, label: t('journey.stats.days') },
{ value: current.stats.entries, label: t('journey.stats.entries') },
{ value: current.stats.photos, label: t('journey.stats.photos') },
{ value: current.stats.cities, label: t('journey.stats.cities') },
].map(s => (
<div key={s.label}>
<div className="text-[20px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white">{s.value}</div>
<div className="text-[10px] uppercase tracking-[0.08em] text-zinc-500 font-medium">{s.label}</div>
<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-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
<div className="text-[9px] uppercase tracking-[0.1em] text-zinc-400 dark:text-zinc-500 font-semibold">{s.label}</div>
</div>
))}
</div>
@@ -440,7 +434,7 @@ export default function JourneyDetailPage() {
<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-[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>
</div>
</div>
@@ -674,7 +668,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
<div key={date}>
{/* Day separator */}
<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>
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
</div>
@@ -834,14 +828,16 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
{/* Header */}
<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">
<button
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"
>
<Plus size={12} />
Upload
{t('common.upload')}
</button>
{availableProviders.map(p => (
<button
@@ -873,7 +869,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))}
>
<img
src={photo.provider !== 'local' ? photoUrl(photo, 'original') : photoUrl(photo)}
src={photoUrl(photo, 'original')}
alt={photo.caption || ''}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
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">
<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>
</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 }) {
const src = photo.provider !== 'local' ? photoUrl(photo, 'original') : photoUrl(photo)
const src = photoUrl(photo, 'original')
return (
<img
src={src}
@@ -1368,7 +1364,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
}) {
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 [albums, setAlbums] = useState<any[]>([])
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
@@ -1413,6 +1409,8 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
useEffect(() => {
if (filter === 'trip' && tripRange.from && tripRange.to) {
searchPhotos(tripRange.from, tripRange.to)
} else if (filter === 'all') {
searchPhotos('', '')
} else if (filter === 'album' && albums.length === 0) {
loadAlbums()
}
@@ -1432,7 +1430,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const targetLabel = targetEntryId
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries')
: 'Gallery'
: t('journey.picker.newGallery')
return (
<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 */}
<div className="flex gap-1.5 mb-3">
{[
{ id: 'trip' as const, label: t('journey.trips.link') },
{ id: 'custom' as const, label: t('common.edit') },
{ id: 'album' as const, label: t('journey.share.gallery') },
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
{ id: 'all' as const, label: t('journey.picker.allPhotos') },
{ id: 'album' as const, label: t('journey.picker.albums') },
].map(f => (
<button
key={f.id}
@@ -1479,11 +1478,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
<>
<Calendar size={13} className="text-zinc-400" />
<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 className="text-zinc-400">&mdash;</span>
<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 className="ml-1 text-zinc-400">
({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>
<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">
Search
{t('journey.picker.search')}
</button>
</div>
)}
@@ -1522,16 +1521,16 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
</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>
{/* 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="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
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"
@@ -1552,10 +1551,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
}`}
>
<Camera size={12} />
Gallery
{t('journey.picker.newGallery')}
</button>
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
{entries.map(e => (
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
<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
key={e.id}
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'
}`}
>
{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>
))}
</div>
@@ -1638,19 +1639,20 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{/* 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">
<span className="text-[12px] text-zinc-500">
<strong className="text-zinc-900 dark:text-white">{selected.size}</strong> selected
<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">
<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>
<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">
Cancel
{t('common.cancel')}
</button>
<button
onClick={() => onAdd([...selected], targetEntryId)}
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"
>
Add {selected.size > 0 ? `(${selected.size})` : ''}
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
</button>
</div>
</div>
@@ -1666,6 +1668,7 @@ function DatePicker({ value, onChange, tripDates }: {
onChange: (date: string) => void
tripDates?: Set<string>
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [viewMonth, setViewMonth] = useState(() => {
const d = value ? new Date(value + 'T00:00:00') : new Date()
@@ -1674,7 +1677,7 @@ function DatePicker({ value, onChange, tripDates }: {
const daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate()
const firstDow = new Date(viewMonth.year, viewMonth.month, 1).getDay()
const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString('en', { month: 'long', year: 'numeric' })
const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
const prevMonth = () => {
setViewMonth(p => p.month === 0 ? { year: p.year - 1, month: 11 } : { ...p, month: p.month - 1 })
@@ -1689,7 +1692,7 @@ function DatePicker({ value, onChange, tripDates }: {
for (let i = 0; i < firstDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' }) : 'Select date'
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : t('journey.picker.selectDate')
return (
<div className="relative">
@@ -1719,8 +1722,8 @@ function DatePicker({ value, onChange, tripDates }: {
{/* Weekday headers */}
<div className="grid grid-cols-7 mb-1">
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(d => (
<div key={d} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
{Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i).toLocaleDateString(undefined, { weekday: 'narrow' })).map((d, i) => (
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
))}
</div>
@@ -1870,7 +1873,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
onClick={() => fileRef.current?.click()}
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5"
>
<Plus size={13} /> Upload photos
<Plus size={13} /> {t('journey.editor.uploadPhotos')}
</button>
{galleryPhotos.length > 0 && (
<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'
}`}
>
<Image size={13} /> From Gallery
<Image size={13} /> {t('journey.editor.fromGallery')}
</button>
)}
</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"
>
<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>
))}
{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">
{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' : ''}`}>
<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 && (
<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"
>
Make 1st
{t('journey.editor.makeFirst')}
</button>
)}
<button
@@ -2017,7 +2020,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
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"
>
<Plus size={13} strokeWidth={2.5} /> Add another
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
</button>
</div>
</div>
@@ -2051,7 +2054,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
onClick={() => setCons([...cons, ''])}
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-red-200 dark:border-red-800/40 rounded-[10px] text-[12px] font-medium text-red-700 dark:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-colors"
>
<Plus size={13} strokeWidth={2.5} /> Add another
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
</button>
</div>
</div>
@@ -2129,7 +2132,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
)}
{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">
Searching...
{t('journey.editor.searching')}
</div>
)}
</div>
@@ -2268,7 +2271,7 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
disabled={adding === t.id}
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200 disabled:opacity-50"
>
{adding === t.id ? '...' : 'Link'}
{adding === t.id ? '...' : t('journey.trips.link')}
</button>
</div>
))}
@@ -2376,19 +2379,19 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
{/* Role selector */}
<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">
{(['viewer', 'editor'] as const).map(r => (
<button
key={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
? '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'
}`}
>
{r}
{t(`journey.invite.${r}`)}
</button>
))}
</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">
<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
onClick={handleInvite}
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"
>
{sending ? 'Inviting...' : 'Invite'}
{sending ? t('journey.invite.inviting') : t('journey.invite.invite')}
</button>
</div>
</div>
@@ -2471,7 +2474,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
onClick={createLink}
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
>
<Link size={14} /> Create share link
<Link size={14} /> {t('journey.share.createLink')}
</button>
) : (
<div className="flex flex-col gap-3">
@@ -2483,7 +2486,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
onClick={copyLink}
className="flex-shrink-0 px-2.5 py-1 rounded-md bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-700 dark:hover:bg-zinc-200"
>
{copied ? 'Copied!' : 'Copy'}
{copied ? t('journey.share.copied') : t('journey.share.copy')}
</button>
</div>
+4 -4
View File
@@ -97,7 +97,7 @@ const mockJourneyData = {
weather: 'cloudy',
pros_cons: null,
photos: [
{ id: 100, entry_id: 11, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance' },
{ id: 100, entry_id: 11, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance' },
],
},
],
@@ -348,9 +348,9 @@ describe('JourneyPublicPage', () => {
entry_time: null, location_name: null, location_lat: null, location_lng: null,
mood: null, weather: null, pros_cons: null,
photos: [
{ id: 200, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A' },
{ id: 201, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B' },
{ id: 202, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C' },
{ id: 200, entry_id: 20, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A' },
{ id: 201, entry_id: 20, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B' },
{ id: 202, entry_id: 20, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C' },
],
},
],
+3 -3
View File
@@ -26,7 +26,8 @@ interface PublicEntry {
interface PublicPhoto {
id: number
entry_id: number
provider: string
photo_id: number
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
@@ -34,8 +35,7 @@ interface PublicPhoto {
}
function photoUrl(p: PublicPhoto, shareToken: string): string {
if (p.provider === 'local') return `/api/public/journey/${shareToken}/photo/local/${encodeURIComponent(p.file_path || '')}/0/original`
return `/api/public/journey/${shareToken}/photo/${p.provider}/${p.asset_id}/${p.owner_id}/original`
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/original`
}
function formatDate(d: string): { weekday: string; month: string; day: number } {
+7 -5
View File
@@ -42,17 +42,19 @@ export interface JourneyEntry {
export interface JourneyPhoto {
id: number
entry_id: number
provider: 'local' | 'immich' | 'synologyphotos'
photo_id: number
caption?: string | null
sort_order: number
shared: number
created_at: number
// Joined from trek_photos for display
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
thumbnail_path?: string | null
caption?: string | null
sort_order: number
width?: number | null
height?: number | null
shared: number
created_at: number
}
export interface JourneyTrip {