mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user