Feat/journey external picture provider rework (#1632)

* feat(journey): add contextual external photo browser

* fix(Journey) added missing language keys for new Journey feature

* fix(Journey) fixing missing gps requests aswell as added protection against duplicate entries
This commit is contained in:
SimMesg20
2026-07-23 21:35:12 +02:00
committed by GitHub
parent 8855b5581d
commit 4d3d16389e
35 changed files with 618 additions and 44 deletions
@@ -1,25 +1,31 @@
import { useState, useRef } from 'react'
import { useEffect, useState, useRef } from 'react'
import { X, Plus, Image, Minus, Check, MapPin } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
import { useTranslation } from '../../i18n'
import { journeyApi, mapsApi } from '../../api/client'
import { journeyApi, mapsApi, addonsApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto } from '../../store/journeyStore'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, isValidGeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import MarkdownToolbar from './MarkdownToolbar'
import { DatePicker } from './JourneyDetailPageDatePicker'
import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker'
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSave, onUploadPhotos, onDone }: {
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: {
entry: JourneyEntry
journeyId: number
tripDates: Set<string>
galleryPhotos: GalleryPhoto[]
trips: JourneyTrip[]
userId?: number
onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<number>
onSave: (data: Record<string, unknown>, existingEntryId?: number) => Promise<number>
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
onAddProviderPhotos?: (entryId: number, group: PendingProviderGroup) => Promise<void>
onDone: () => void
}) {
const { t } = useTranslation()
@@ -46,8 +52,14 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
const [showGalleryPick, setShowGalleryPick] = useState(false)
const [photoTab, setPhotoTab] = useState<'upload' | 'gallery' | 'external'>('upload')
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
const [providersLoading, setProvidersLoading] = useState(false)
const [externalProvider, setExternalProvider] = useState<string | null>(null)
const [pendingProviderGroups, setPendingProviderGroups] = useState<PendingProviderGroup[]>([])
const fileRef = useRef<HTMLInputElement>(null)
const storyRef = useRef<HTMLTextAreaElement>(null)
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null)
// Track which fields differ from the entry we started editing so we can
// warn before discarding on close/cancel.
@@ -66,11 +78,48 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos
pros.filter(p => p.trim()).join('\n') !== originalPros ||
cons.filter(c => c.trim()).join('\n') !== originalCons ||
pendingFiles.length > 0 ||
pendingLinkIds.length > 0
pendingLinkIds.length > 0 ||
pendingProviderGroups.length > 0
)
const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
useEffect(() => {
if (photoTab !== 'external' || availableProviders.length > 0 || providersLoading) return
let cancelled = false
setProvidersLoading(true)
;(async () => {
try {
const addonsData = await addonsApi.enabled()
const enabled = (addonsData.addons || []).filter((a: any) => a.type === 'photo_provider' && a.enabled)
const connected: { id: string; name: string }[] = []
for (const provider of enabled) {
try {
const response = await fetch(`/api/integrations/memories/${provider.id}/status`, { credentials: 'include' })
if (response.ok && (await response.json()).connected) connected.push({ id: provider.id, name: provider.name })
} catch {}
}
if (!cancelled) {
setAvailableProviders(connected)
if (connected.length > 0) setExternalProvider(current => current || connected[0].id)
}
} catch {}
if (!cancelled) setProvidersLoading(false)
})()
return () => { cancelled = true }
}, [photoTab, availableProviders.length])
const activeExternalProvider = externalProvider || availableProviders[0]?.id || null
const providerExistingAssetIds = new Set<string>()
if (activeExternalProvider) {
photos.forEach(photo => {
if (photo.provider === activeExternalProvider && photo.asset_id) providerExistingAssetIds.add(photo.asset_id)
})
pendingProviderGroups.forEach(group => {
if (group.provider === activeExternalProvider) group.assetIds.forEach(assetId => providerExistingAssetIds.add(assetId))
})
}
const handleClose = () => {
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
onClose()
@@ -90,8 +139,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos
mood: mood || null,
weather: weather || null,
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0)) ? 'entry' : undefined),
})
type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0 || pendingProviderGroups.length > 0)) ? 'entry' : undefined),
}, persistedEntryIdRef.current ?? undefined)
if (entryId > 0) persistedEntryIdRef.current = entryId
// upload queued files after entry is created
if (pendingFiles.length > 0 && entryId) {
const filesToUpload = pendingFiles
@@ -116,6 +166,18 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos
try { await journeyApi.linkPhoto(entryId, photoId) } catch {}
}
}
if (pendingProviderGroups.length > 0 && entryId && onAddProviderPhotos) {
const failed: PendingProviderGroup[] = []
for (const group of pendingProviderGroups) {
try { await onAddProviderPhotos(entryId, group) } catch { failed.push(group) }
}
if (failed.length > 0) {
setPendingProviderGroups(failed)
toast.error(t('journey.editor.externalPhotosPartialFailed', { failed: String(failed.length), total: String(pendingProviderGroups.length) }))
return
}
setPendingProviderGroups([])
}
onDone()
} finally {
setSaving(false)
@@ -131,6 +193,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos
setPendingFiles(prev => [...prev, ...normalized])
}
const contextLocation = isValidGeoPoint({ lat: locationLat ?? NaN, lng: locationLng ?? NaN })
? { lat: locationLat!, lng: locationLng!, name: locationName || undefined }
: null
return (
<div className="fixed inset-0 z-[9999]" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)' }}>
{/* The modal itself is constrained to the feed column on desktop so it
@@ -163,7 +229,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<div className="flex gap-2">
<button
onClick={() => fileRef.current?.click()}
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); fileRef.current?.click() }}
disabled={saving}
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl 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 disabled:opacity-50"
>
@@ -175,7 +241,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos
</button>
{galleryPhotos.length > 0 && (
<button
onClick={() => setShowGalleryPick(!showGalleryPick)}
onClick={() => { setPhotoTab('gallery'); setShowGalleryPick(!showGalleryPick) }}
className={`flex-1 border rounded-xl py-4 text-[12px] text-zinc-500 flex items-center justify-center gap-1.5 ${
showGalleryPick
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
@@ -185,6 +251,17 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos
<Image size={13} /> {t('journey.editor.fromGallery')}
</button>
)}
<button
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
className={`flex-1 border rounded-lg py-4 text-[12px] text-zinc-500 flex items-center justify-center gap-1.5 ${
photoTab === 'external'
? 'border-zinc-900 dark:border-white bg-zinc-50 dark: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} /> {t('journey.editor.externalPhotos') || 'External photos'}
</button>
</div>
{/* Gallery picker — directly below buttons. Safari collapses
@@ -220,6 +297,85 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos
</div>
</div>
)}
{photoTab === 'external' && (
<div className="mt-2 flex flex-col border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden bg-zinc-50 dark:bg-zinc-800/50" style={{ height: 'min(56vh, 520px)' }}>
<div className="px-3 py-2 border-b border-zinc-200 dark:border-zinc-700 flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] font-semibold text-zinc-700 dark:text-zinc-200 truncate">
{t('journey.editor.externalPhotosFor', { date: new Date(entryDate + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) })}
</p>
<p className="text-[10px] text-zinc-400 truncate">
{contextLocation?.name
? `${t('journey.editor.externalPhotosNearby') || 'Nearby photos first'} · ${contextLocation.name}`
: (t('journey.editor.externalPhotosNoLocation') || 'All photos from this day')}
</p>
</div>
{pendingProviderGroups.length > 0 && (
<button onClick={() => setPendingProviderGroups([])} className="text-[10px] text-zinc-500 hover:text-zinc-900 dark:hover:text-white whitespace-nowrap">
{pendingProviderGroups.reduce((sum, group) => sum + group.assetIds.length, 0)} {t('journey.editor.externalPhotosQueued') || 'queued'} · {t('common.clear') || 'Clear'}
</button>
)}
</div>
{providersLoading ? (
<div className="flex justify-center py-8"><div className="w-5 h-5 border-2 border-zinc-300 border-t-zinc-700 rounded-full animate-spin" /></div>
) : availableProviders.length === 0 ? (
<div className="text-center py-10 px-4 text-[12px] text-zinc-500">{t('journey.editor.externalPhotosUnavailable') || 'No connected photo providers are available.'}</div>
) : (
<div className="h-full min-h-0 flex flex-col">
<div className="flex gap-1 px-3 py-2 border-b border-zinc-200 dark:border-zinc-700 overflow-x-auto">
{availableProviders.map(provider => (
<button
key={provider.id}
data-testid={`journey-external-provider-${provider.id}`}
onClick={() => setExternalProvider(provider.id)}
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap ${externalProvider === provider.id ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-700'}`}
>
{provider.name}
</button>
))}
</div>
{activeExternalProvider && (
<div className="flex-1 min-h-0">
<ProviderPicker
key={`${activeExternalProvider}-${entryDate}`}
provider={activeExternalProvider}
userId={userId}
entries={[entry]}
trips={trips}
existingAssetIds={providerExistingAssetIds}
initialDate={entryDate}
contextLocation={contextLocation}
initialEntryId={entry.id || null}
embedded
onClose={() => setExternalProvider(null)}
onAdd={async groups => {
setPendingProviderGroups(previous => {
const next = [...previous]
for (const group of groups) {
const existing = next.find(item => item.provider === activeExternalProvider && item.passphrase === group.passphrase)
if (existing) {
const seen = new Set(existing.assetIds)
group.assetIds.forEach((assetId, index) => {
if (seen.has(assetId)) return
seen.add(assetId)
existing.assetIds.push(assetId)
existing.mediaTypes?.push(group.mediaTypes?.[index] || 'image')
})
} else {
next.push({ ...group, provider: activeExternalProvider })
}
}
return next
})
setExternalProvider(null)
}}
/>
</div>
)}
</div>
)}
</div>
)}
{(photos.length > 0 || pendingFiles.length > 0) && (
<div className="mt-3">
<div className="flex flex-wrap gap-2">
@@ -2,21 +2,27 @@ import { useEffect, useState, useRef, useMemo } from 'react'
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
import { groupPhotosByDate } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
import { DatePicker } from './JourneyDetailPageDatePicker'
export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] }
export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd, initialDate, contextLocation, initialEntryId, embedded = false }: {
provider: string
userId: number
entries: JourneyEntry[]
trips: JourneyTrip[]
existingAssetIds: Set<string>
onClose: () => void
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string; mediaTypes?: string[] }>, entryId: number | null) => Promise<void>
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>
initialDate?: string
contextLocation?: (GeoPoint & { name?: string }) | null
initialEntryId?: number | null
embedded?: boolean
}) {
const { t } = useTranslation()
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip')
const [photos, setPhotos] = useState<any[]>([])
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
@@ -30,7 +36,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(new Map())
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null)
const [addToOpen, setAddToOpen] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const gridRef = useRef<HTMLDivElement>(null)
@@ -104,7 +110,9 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
// load on mount / filter change
useEffect(() => {
if (filter === 'trip' && tripRange.from && tripRange.to) {
if (filter === 'day' && initialDate) {
searchPhotos(initialDate, initialDate)
} else if (filter === 'trip' && tripRange.from && tripRange.to) {
searchPhotos(tripRange.from, tripRange.to)
} else if (filter === 'all') {
searchPhotos('', '')
@@ -117,6 +125,11 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
if (customFrom && customTo) searchPhotos(customFrom, customTo)
}
const sortedPhotos = useMemo(
() => sortProviderPhotos(photos, contextLocation),
[photos, contextLocation?.lat, contextLocation?.lng],
)
const toggleAsset = (id: string) => {
setSelected(prev => {
const next = new Map(prev)
@@ -135,24 +148,38 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
: t('journey.picker.newGallery')
return (
<div className="fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]" onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<div
data-testid={embedded ? 'journey-provider-picker-embedded' : undefined}
className={embedded
? 'w-full h-full min-h-0 flex flex-col overflow-hidden'
: 'fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]'}
onClick={embedded ? undefined : onClose}
onTouchMove={e => { if (!embedded && e.target === e.currentTarget) e.preventDefault() }}
>
<div
className={embedded
? 'bg-white dark:bg-zinc-900 w-full h-full flex flex-col overflow-hidden'
: 'bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden'}
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
{!embedded && <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
</div>}
{/* Filter bar */}
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
{/* Tabs */}
<div className="flex gap-1.5 mb-3">
{[
...(initialDate ? [{ id: 'day' as const, label: t('journey.picker.day') || 'This day' }] : []),
{ 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'), short: t('common.all') },
@@ -178,7 +205,16 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
</div>
{/* Filter content — always visible row */}
<div className="min-h-[36px] flex items-center">
{(!embedded || filter !== 'day') && <div className="min-h-[36px] flex items-center">
{filter === 'day' && initialDate && (
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
<Calendar size={13} className="text-zinc-400" />
<span className="font-medium text-zinc-900 dark:text-white">
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</span>
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
</div>
)}
{filter === 'trip' && (
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
{tripRange.from && tripRange.to ? (
@@ -231,11 +267,11 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
</div>
)}
</div>
</div>}
</div>
{/* 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 flex-shrink-0">
{!embedded && <div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
<div className="relative flex items-center gap-2">
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
<button
@@ -280,11 +316,11 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
</>
)}
</div>
</div>
</div>}
{/* Select all bar — sticky above grid */}
{!loading && photos.length > 0 && (() => {
const selectable = photos.filter((a: any) => !existingAssetIds.has(a.id))
{!loading && sortedPhotos.length > 0 && (() => {
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id))
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
if (selectable.length === 0) return null
return (
@@ -318,7 +354,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
<div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
</div>
) : photos.length === 0 ? (
) : sortedPhotos.length === 0 ? (
<div className="text-center py-12">
<p className="text-[13px] text-zinc-500">
{filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')}
@@ -326,11 +362,13 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
</div>
) : (
<div>
{groupPhotosByDate(photos).map(group => (
{groupPhotosByDate(sortedPhotos).map(group => (
<div key={group.date}>
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
{group.label}
</p>
{(!embedded || filter !== 'day') && (
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
{group.label}
</p>
)}
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
{group.assets.map((asset: any) => {
const isSelected = selected.has(asset.id)
@@ -3759,4 +3759,95 @@ describe('JourneyDetailPage', () => {
});
});
});
describe('FE-PAGE-JOURNEYDETAIL-153: Contextual external photos', () => {
it('shows the External photos tab and searches the selected entry day', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const searches: Array<{ from?: string; to?: string }> = [];
server.use(
http.get('/api/integrations/memories/:provider/status', () => HttpResponse.json({ connected: true })),
http.post('/api/integrations/memories/:provider/search', async ({ request }) => {
searches.push(await request.json() as { from?: string; to?: string });
return HttpResponse.json({ assets: [{ id: 'context-1', takenAt: '2026-03-15T12:00:00Z', city: 'Rome' }], hasMore: false });
}),
);
await renderAndWait();
await openEntryEditor(user);
await user.click(screen.getByRole('button', { name: /external photos/i }));
await waitFor(() => {
expect(screen.getByTestId('journey-external-provider-immich')).toBeInTheDocument();
});
await user.click(screen.getByTestId('journey-external-provider-immich'));
await waitFor(() => expect(screen.getByTestId('journey-provider-picker-embedded')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText(/This day|journey\.picker\.day/)).toBeInTheDocument());
await waitFor(() => expect(searches.length).toBeGreaterThan(0));
expect(searches[0].from).toBe(searches[0].to);
expect(screen.getByText('This day')).toBeInTheDocument();
expect(document.querySelector('img[src*="/api/integrations/memories/"]')).toBeTruthy();
await user.click(screen.getByText('Trip Period'));
await waitFor(() => expect(searches.some(search => search.from === '2026-03-14' && search.to === '2026-03-20')).toBe(true));
expect(screen.queryByText(/No trips linked/i)).not.toBeInTheDocument();
});
it('does not create a duplicate when provider-photo attachment fails after creating the entry', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let createCalls = 0;
let updateCalls = 0;
let providerCalls = 0;
server.use(
http.get('/api/integrations/memories/:provider/status', () => HttpResponse.json({ connected: true })),
http.post('/api/integrations/memories/:provider/search', () => HttpResponse.json({
assets: [{ id: 'context-1', takenAt: '2026-03-15T12:00:00Z', city: 'Rome' }],
hasMore: false,
})),
http.post('/api/journeys/1/entries', () => {
createCalls++;
return HttpResponse.json({
id: 88, journey_id: 1, author_id: 1, type: 'entry',
entry_date: '2026-03-15', title: null, story: null, location_name: null,
location_lat: null, location_lng: null, mood: null, weather: null,
tags: [], pros_cons: null, visibility: 'private', sort_order: 0,
entry_time: null, photos: [], created_at: now, updated_at: now,
});
}),
http.patch('/api/journeys/entries/88', () => {
updateCalls++;
return HttpResponse.json({ id: 88 });
}),
http.post('/api/journeys/entries/88/provider-photos', () => {
providerCalls++;
return providerCalls === 1
? HttpResponse.json({ error: 'provider unavailable' }, { status: 502 })
: HttpResponse.json({ added: 1 });
}),
);
await renderAndWait();
await openEntryEditor(user);
await user.click(screen.getByRole('button', { name: /external photos/i }));
await waitFor(() => expect(screen.getByTestId('journey-external-provider-immich')).toBeInTheDocument());
await user.click(screen.getByTestId('journey-external-provider-immich'));
const picker = await waitFor(() => screen.getByTestId('journey-provider-picker-embedded'));
await waitFor(() => expect(picker.querySelector('img[src*="/api/integrations/memories/"]')).toBeTruthy());
const photo = picker.querySelector('img[src*="/api/integrations/memories/"]')!;
await user.click(photo.closest('[class*="aspect-square"]') as HTMLElement);
await user.click(screen.getByRole('button', { name: /^Add \(1\)/ }));
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(providerCalls).toBe(1));
expect(createCalls).toBe(1);
expect(screen.getByText('New Entry')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(providerCalls).toBe(2));
expect(createCalls).toBe(1);
expect(updateCalls).toBe(1);
});
});
});
+10 -4
View File
@@ -462,21 +462,27 @@ function JourneyDetailPageDesktop() {
entry={editingEntry}
journeyId={current.id}
tripDates={tripDates}
trips={current.trips}
galleryPhotos={current.gallery || []}
userId={useAuthStore.getState().user?.id || 0}
onClose={() => setEditingEntry(null)}
onSave={async (data) => {
let entryId = editingEntry.id
if (editingEntry.id === 0) {
onSave={async (data, existingEntryId) => {
const currentEntryId = existingEntryId ?? editingEntry.id
let entryId = currentEntryId
if (currentEntryId === 0) {
const created = await useJourneyStore.getState().createEntry(current.id, data)
entryId = created.id
} else {
await updateEntry(editingEntry.id, data)
await updateEntry(currentEntryId, data)
}
return entryId
}}
onUploadPhotos={async (entryId, files, cbs) => {
return await uploadPhotos(entryId, files, cbs)
}}
onAddProviderPhotos={async (entryId, group) => {
await journeyApi.addProviderPhotos(entryId, group.provider, group.assetIds, undefined, group.passphrase, group.mediaTypes)
}}
onDone={() => {
setEditingEntry(null)
loadJourney(Number(id))
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { distanceBetweenGeoPoints, sortProviderPhotos } from './JourneyDetailPage.helpers';
describe('Journey provider photo ranking', () => {
it('places GPS photos nearest the selected Journey location and keeps photos without GPS', () => {
const photos = [
{ id: 'far', lat: 41.95, lng: 12.5 },
{ id: 'unknown', lat: null, lng: null },
{ id: 'near', lat: 41.901, lng: 12.501 },
];
expect(sortProviderPhotos(photos, { lat: 41.9, lng: 12.5 }).map((photo) => photo.id)).toEqual([
'near',
'far',
'unknown',
]);
});
it('preserves provider order when the selected entry has no valid location', () => {
const photos = [{ id: 'first' }, { id: 'second' }];
expect(sortProviderPhotos(photos, { lat: 200, lng: 12 }).map((photo) => photo.id)).toEqual(['first', 'second']);
});
it('calculates a zero distance for identical coordinates', () => {
expect(distanceBetweenGeoPoints({ lat: 41.9, lng: 12.5 }, { lat: 41.9, lng: 12.5 })).toBe(0);
});
});
@@ -45,3 +45,57 @@ export function groupPhotosByDate(photos: any[]): { date: string; label: string;
assets,
}))
}
export interface ProviderPhotoAsset {
id: string
takenAt?: string | null
lat?: number | null
lng?: number | null
[key: string]: unknown
}
export interface GeoPoint {
lat: number
lng: number
}
export function isValidGeoPoint(point: Partial<GeoPoint> | null | undefined): point is GeoPoint {
return !!point && Number.isFinite(point.lat) && Number.isFinite(point.lng)
&& point.lat >= -90 && point.lat <= 90 && point.lng >= -180 && point.lng <= 180
}
/** Return the great-circle distance in metres between two coordinates. */
export function distanceBetweenGeoPoints(a: GeoPoint, b: GeoPoint): number {
const earthRadius = 6371000
const toRadians = (value: number) => value * Math.PI / 180
const dLat = toRadians(b.lat - a.lat)
const dLng = toRadians(b.lng - a.lng)
const latA = toRadians(a.lat)
const latB = toRadians(b.lat)
const h = Math.sin(dLat / 2) ** 2 + Math.cos(latA) * Math.cos(latB) * Math.sin(dLng / 2) ** 2
return 2 * earthRadius * Math.asin(Math.sqrt(Math.min(1, h)))
}
/**
* Keep every asset, but put assets with usable GPS nearest to the selected
* Journey location. The original provider order is the stable fallback.
*/
export function sortProviderPhotos<T extends ProviderPhotoAsset>(photos: T[], location?: GeoPoint | null): T[] {
if (!isValidGeoPoint(location)) return photos
return photos
.map((photo, index) => ({
photo,
index,
distance: isValidGeoPoint({ lat: photo.lat ?? NaN, lng: photo.lng ?? NaN })
? distanceBetweenGeoPoints(location, { lat: photo.lat!, lng: photo.lng! })
: null,
}))
.sort((a, b) => {
if (a.distance === null && b.distance === null) return a.index - b.index
if (a.distance === null) return 1
if (b.distance === null) return -1
return a.distance - b.distance || a.index - b.index
})
.map(item => item.photo)
}
@@ -67,6 +67,11 @@ export type AlbumsList = {
export type Asset = {
id: string;
takenAt: string;
mediaType?: string;
city?: string | null;
country?: string | null;
lat?: number | null;
lng?: number | null;
};
export type AssetsList = {
@@ -289,4 +294,4 @@ export async function pipeAsset(url: string, response: Response, headers?: Recor
response.status(500).json({ error: 'Failed to fetch asset' });
}
}
}
}
@@ -198,6 +198,7 @@ export async function searchPhotos(
// `isVisible` either, so they still return hidden assets. isVisibleAsset()
// below is the only guard on those versions. Do not remove it.
visibility: 'timeline',
withExif: true,
size,
page,
}),
@@ -217,6 +218,8 @@ export async function searchPhotos(
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
lat: typeof a.exifInfo?.latitude === 'number' ? a.exifInfo.latitude : null,
lng: typeof a.exifInfo?.longitude === 'number' ? a.exifInfo.longitude : null,
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
}));
return { assets, hasMore: items.length >= size };
@@ -278,8 +278,8 @@ function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): AssetInfo {
aperture: exif.aperture || null,
shutter: exif.exposure_time || null,
iso: exif.iso || null,
lat: gps.latitude || null,
lng: gps.longitude || null,
lat: typeof gps.latitude === 'number' ? gps.latitude : null,
lng: typeof gps.longitude === 'number' ? gps.longitude : null,
orientation: item.additional?.orientation || null,
description: item.additional?.description || null,
width: item.additional?.resolution?.width || null,
@@ -555,7 +555,7 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s
offset,
limit,
keyword: '.',
additional: ['thumbnail', 'address'],
additional: ['thumbnail', 'address', 'gps'],
};
if (from || to) {
@@ -703,4 +703,3 @@ export async function streamSynologyAsset(
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
await pipeAsset(url, response, undefined, undefined, 'public, max-age=86400', { rejectUnauthorized: !synology_credentials.data.synology_skip_ssl })
}
@@ -123,7 +123,7 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
json: () => Promise.resolve({
assets: {
items: [
{ id: 'asset-search-1', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France' } },
{ id: 'asset-search-1', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France', latitude: 48.8566, longitude: 2.3522 } },
],
},
}),
@@ -347,8 +347,9 @@ describe('Immich browse and search', () => {
expect(res.status).toBe(200);
expect(Array.isArray(res.body.assets)).toBe(true);
expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France' });
expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France', lat: 48.8566, lng: 2.3522 });
expect(typeof res.body.hasMore).toBe('boolean');
expect(immichState.searchCalls[0]).toMatchObject({ withExif: true });
});
it('IMMICH-043 — POST /search when upstream throws returns 502', async () => {
@@ -977,7 +977,7 @@ describe('Synology searchSynologyPhotos date range', () => {
thumbnail: { cache_key: '201_abc' },
address: { city: 'Kyoto', country: 'Japan', state: 'Kyoto' },
exif: {},
gps: {},
gps: { latitude: 35.0116, longitude: 135.7681 },
resolution: { width: 4000, height: 3000 },
orientation: 1,
description: null,
@@ -997,6 +997,7 @@ describe('Synology searchSynologyPhotos date range', () => {
expect(res.status).toBe(200);
expect(Array.isArray(res.body.assets)).toBe(true);
expect(res.body.assets[0]).toMatchObject({ lat: 35.0116, lng: 135.7681 });
// Verify date parameters were forwarded in the Synology API request body
expect(capturedBody).not.toBeNull();
@@ -1006,6 +1007,9 @@ describe('Synology searchSynologyPhotos date range', () => {
expect(Number(startTime)).toBeGreaterThan(0);
expect(endTime).toBeDefined();
expect(Number(endTime)).toBeGreaterThan(Number(startTime));
const additional = JSON.parse(capturedBody!.get('additional') || '[]') as string[];
expect(additional).toContain('gps');
});
it('SYNO-071 — POST /search without date range omits start_time and end_time', async () => {
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.pdf.theEnd': 'The End', // en-fallback
'journey.pdf.saveAsPdf': 'Save as PDF', // en-fallback
'journey.pdf.pages': 'pages', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': "Vols eliminar {username} d'aquesta travesia?",
'journey.contributors.removed': 'Col·laborador eliminat',
'journey.contributors.removeFailed': "No s'ha pogut eliminar el col·laborador",
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.picker.selectDate': 'Datum wählen',
'journey.picker.search': 'Suchen',
'journey.detail.journeyTab': 'Journey', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -111,6 +111,13 @@ const journey: TranslationStrings = {
'journey.editor.uploadPartialFailed': '{failed} of {total} photos failed — save again to retry',
'journey.editor.fromGallery': 'From Gallery',
'journey.editor.allPhotosAdded': 'All photos already added',
'journey.editor.externalPhotos': 'External photos',
'journey.editor.externalPhotosFor': 'Photos for {date}',
'journey.editor.externalPhotosNearby': 'Nearby photos first',
'journey.editor.externalPhotosNoLocation': 'All photos from this day',
'journey.editor.externalPhotosQueued': 'queued',
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.',
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry',
'journey.editor.writeStory': 'Write your story...',
'journey.editor.prosCons': 'Pros & Cons',
'journey.editor.pros': 'Pros',
@@ -217,6 +224,7 @@ const journey: TranslationStrings = {
'journey.pdf.saveAsPdf': 'Save as PDF',
'journey.pdf.pages': 'pages',
'journey.picker.tripPeriod': 'Trip Period',
'journey.picker.day': 'This day',
'journey.picker.dateRange': 'Date Range',
'journey.picker.allPhotos': 'All Photos',
'journey.picker.albums': 'Albums',
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -229,5 +229,13 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -230,5 +230,13 @@ const journey: TranslationStrings = {
'journey.picker.noAlbums': 'Δεν βρέθηκαν άλμπουμ',
'journey.picker.selectDate': 'Επιλογή ημερομηνίας',
'journey.picker.search': 'Αναζήτηση',
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -229,5 +229,13 @@ const journey: TranslationStrings = {
'journey.editor.uploadPartialFailed':
'{total}枚中{failed}枚の写真がアップロードに失敗しました — もう一度保存して再試行してください',
'journey.photosUploadFailed': '一部の写真をアップロードできませんでした',
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -229,5 +229,13 @@ const journey: TranslationStrings = {
'journey.editor.uploadPartialFailed':
'{total}개 중 {failed}개의 사진을 업로드하지 못했습니다 — 다시 저장하여 재시도하세요',
'journey.photosUploadFailed': '일부 사진을 업로드하지 못했습니다',
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -229,5 +229,13 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.picker.noAlbums': 'Inga album hittades',
'journey.picker.selectDate': 'Välj datum',
'journey.picker.search': 'Sök',
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.editor.uploadFailed': 'Fotoğraf yüklenemedi',
'journey.editor.uploadPartialFailed': '{total} fotoğraftan {failed} tanesi başarısız — tekrar denemek için kaydedin',
'journey.photosUploadFailed': 'Bazı fotoğraflar yüklenemedi',
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -229,5 +229,13 @@ const journey: TranslationStrings = {
'journey.editor.uploadPartialFailed':
'{failed} з {total} фото не вдалося завантажити — збережіть ще раз, щоб повторити',
'journey.photosUploadFailed': 'Деякі фото не вдалося завантажити',
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -228,5 +228,13 @@ const journey: TranslationStrings = {
'journey.picker.noAlbums': 'Không tìm thấy album nào',
'journey.picker.selectDate': 'Chọn ngày',
'journey.picker.search': 'Tìm kiếm',
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -227,5 +227,13 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+8
View File
@@ -227,5 +227,13 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': '要将 {username} 从此旅程中移除吗?',
'journey.contributors.removed': '已移除贡献者',
'journey.contributors.removeFailed': '移除贡献者失败',
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
'journey.picker.day': 'This day', // en-fallback
};
export default journey;
+6
View File
@@ -41,6 +41,12 @@ Each entry corresponds to a day in your journey. The entry editor provides:
- **Location** — pin the entry to a map location.
- **Time** — optionally record a time of day for the entry.
### External photos
The entry editor includes an **External photos** tab for connected Immich and Synology Photos libraries. It searches the selected calendar day automatically. When the entry has a pinned location, photos with GPS metadata are shown nearest to that location first. Photos without GPS, and photos taken farther away on the same day, remain available below the nearby results; no distance cutoff is applied.
External selections are queued with the other editor changes and saved only when you click **Save**. If a provider is unavailable or its metadata has no GPS coordinates, Journey falls back to the normal date-based photo list.
## Mobile timeline
On mobile, entries are displayed in a horizontal scrolling timeline of card thumbnails. Tap a card to open the full entry view in a modal sheet. Each card shows the entry's first photo (or a placeholder pin), date, day number, mood icon, and weather icon.