+ )
+}
+
+// ── Date Picker ───────────────────────────────────────────────────────────
+
+function DatePicker({ value, onChange, tripDates }: {
+ value: string
+ onChange: (date: string) => void
+ tripDates?: Set
+}) {
+ const [open, setOpen] = useState(false)
+ const [viewMonth, setViewMonth] = useState(() => {
+ const d = value ? new Date(value + 'T00:00:00') : new Date()
+ return { year: d.getFullYear(), month: d.getMonth() }
+ })
+
+ const daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate()
+ const firstDow = new Date(viewMonth.year, viewMonth.month, 1).getDay()
+ const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString('en', { month: 'long', year: 'numeric' })
+
+ const prevMonth = () => {
+ setViewMonth(p => p.month === 0 ? { year: p.year - 1, month: 11 } : { ...p, month: p.month - 1 })
+ }
+ const nextMonth = () => {
+ setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })
+ }
+
+ const pad = (n: number) => String(n).padStart(2, '0')
+
+ const cells: (number | null)[] = []
+ for (let i = 0; i < firstDow; i++) cells.push(null)
+ for (let d = 1; d <= daysInMonth; d++) cells.push(d)
+
+ const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' }) : 'Select date'
+
+ return (
+
+
+
+ {open && (
+ <>
+
setOpen(false)} />
+
+ {/* Month nav */}
+
+
+
{monthName}
+
+
+
+ {/* Weekday headers */}
+
+ {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(d => (
+
{d}
+ ))}
+
+
+ {/* Day grid */}
+
+ {cells.map((day, i) => {
+ if (day === null) return
+ const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
+ const isSelected = dateStr === value
+ const isTrip = tripDates?.has(dateStr)
+ const isToday = dateStr === new Date().toISOString().split('T')[0]
+
+ return (
+
+ )
+ })}
+
+
+ >
+ )}
+
+ )
+}
+
+function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSave, onUploadPhotos, onDone }: {
+ entry: JourneyEntry
+ journeyId: number
+ tripDates: Set
+ galleryPhotos: JourneyPhoto[]
+ onClose: () => void
+ onSave: (data: Record) => Promise
+ onUploadPhotos: (entryId: number, formData: FormData) => Promise
+ onDone: () => void
+}) {
+ const { t } = useTranslation()
+ const [title, setTitle] = useState(entry.title || '')
+ const [story, setStory] = useState(entry.story || '')
+ const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
+ const [entryTime, setEntryTime] = useState(entry.entry_time || '')
+ const [locationName, setLocationName] = useState(entry.location_name || '')
+ const [locationLat, setLocationLat] = useState(entry.location_lat ?? null)
+ const [locationLng, setLocationLng] = useState(entry.location_lng ?? null)
+ const [locationQuery, setLocationQuery] = useState('')
+ const [locationResults, setLocationResults] = useState<{ name: string; address?: string; lat: number; lng: number }[]>([])
+ const [locationSearching, setLocationSearching] = useState(false)
+ const [showLocationResults, setShowLocationResults] = useState(false)
+ const locationTimerRef = useRef | null>(null)
+ const [mood, setMood] = useState(entry.mood || '')
+ const [weather, setWeather] = useState(entry.weather || '')
+ const [pros, setPros] = useState(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
+ const [cons, setCons] = useState(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
+ const [saving, setSaving] = useState(false)
+ const [photos, setPhotos] = useState(entry.photos || [])
+ const [pendingFiles, setPendingFiles] = useState([])
+ const [pendingLinkIds, setPendingLinkIds] = useState([])
+ const [showGalleryPick, setShowGalleryPick] = useState(false)
+ const fileRef = useRef(null)
+ const storyRef = useRef(null)
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ const entryId = await onSave({
+ title: title || null,
+ story: story || null,
+ entry_date: entryDate,
+ entry_time: entryTime || null,
+ location_name: locationName || null,
+ location_lat: locationLat,
+ location_lng: locationLng,
+ 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()) ? 'entry' : undefined,
+ })
+ // upload queued files after entry is created
+ if (pendingFiles.length > 0 && entryId) {
+ const formData = new FormData()
+ for (const f of pendingFiles) formData.append('photos', f)
+ await onUploadPhotos(entryId, formData)
+ }
+ // link gallery photos that were picked before save
+ if (pendingLinkIds.length > 0 && entryId) {
+ for (const photoId of pendingLinkIds) {
+ try { await journeyApi.linkPhoto(entryId, photoId) } catch {}
+ }
+ }
+ onDone()
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const files = e.target.files
+ if (!files?.length) return
+ if (entry.id === 0) {
+ // queue files for upload after save
+ setPendingFiles(prev => [...prev, ...Array.from(files)])
+ } else {
+ const formData = new FormData()
+ for (const f of files) formData.append('photos', f)
+ const newPhotos = await onUploadPhotos(entry.id, formData)
+ if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos])
+ }
+ }
+
+ return (
+
+
+
+
+
{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
+
+
+
+
+
setTitle(e.target.value)}
+ placeholder={t('journey.editor.titlePlaceholder')}
+ className="w-full text-[20px] font-medium bg-transparent border-0 border-b border-transparent focus:border-zinc-300 dark:focus:border-zinc-600 outline-none text-zinc-900 dark:text-white placeholder:text-zinc-400 pb-2"
+ />
+
+
+
{ (e.target as HTMLInputElement).value = '' }} className="hidden" />
+
+
+ {galleryPhotos.length > 0 && (
+
+ )}
+
+
+ {/* Gallery picker — directly below buttons */}
+ {showGalleryPick && (
+
+
+ {galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => (
+
{
+ if (entry.id > 0) {
+ try {
+ const linked = await journeyApi.linkPhoto(entry.id, gp.id)
+ if (linked) setPhotos(prev => [...prev, linked])
+ } catch {}
+ } else {
+ setPendingLinkIds(prev => [...prev, gp.id])
+ setPhotos(prev => [...prev, gp])
+ }
+ }}
+ className="aspect-square rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
+ >
+
})
{ if (gp.provider !== 'local') { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig } }} />
+
+ ))}
+ {galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
+
{t('journey.editor.allPhotosAdded')}
+ )}
+
+
+ )}
+ {(photos.length > 0 || pendingFiles.length > 0) && (
+
+
+ {photos.map((p, idx) => (
+
1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
+
})
{ if (p.provider !== 'local') { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig } }} />
+ {idx === 0 && photos.length > 1 && (
+
{t('journey.editor.photoFirst')}
+ )}
+ {idx > 0 && photos.length > 1 && (
+
+ )}
+
+
+ ))}
+ {pendingFiles.map((f, i) => (
+
+
})
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ {/* Pros & Cons */}
+
+
+ {t('journey.editor.prosCons')}
+
+
+ {/* Pros */}
+
+
+
+
+
+
{t('journey.editor.pros')}
+
+
+ {pros.map((p, i) => (
+
+
+ { const next = [...pros]; next[i] = e.target.value; setPros(next) }}
+ placeholder={t('journey.editor.proPlaceholder')}
+ className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-green-400 dark:placeholder:text-green-600"
+ />
+ {pros.length > 1 && (
+
+ )}
+
+ ))}
+
+
+
+
+ {/* Cons */}
+
+
+
+
+
+
{t('journey.editor.cons')}
+
+
+ {cons.map((c, i) => (
+
+
+ { const next = [...cons]; next[i] = e.target.value; setCons(next) }}
+ placeholder={t('journey.editor.conPlaceholder')}
+ className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-red-400 dark:placeholder:text-red-600"
+ />
+ {cons.length > 1 && (
+
+ )}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ const q = e.target.value
+ setLocationQuery(q)
+ setShowLocationResults(true)
+ if (locationTimerRef.current) clearTimeout(locationTimerRef.current)
+ if (q.trim().length >= 2) {
+ locationTimerRef.current = setTimeout(async () => {
+ setLocationSearching(true)
+ try {
+ const res = await mapsApi.search(q)
+ setLocationResults((res.places || []).slice(0, 6).map((p: any) => ({
+ name: p.name, address: p.address, lat: Number(p.lat), lng: Number(p.lng),
+ })))
+ } catch { setLocationResults([]) }
+ finally { setLocationSearching(false) }
+ }, 400)
+ } else {
+ setLocationResults([])
+ }
+ }}
+ onFocus={() => { if (locationResults.length > 0) setShowLocationResults(true) }}
+ placeholder={t('journey.editor.searchLocation')}
+ className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
+ />
+ {locationLat && (
+
+
+
+ )}
+
+ {showLocationResults && locationResults.length > 0 && (
+ <>
+
setShowLocationResults(false)} />
+
+ {locationResults.map((r, i) => (
+
+ ))}
+
+ >
+ )}
+ {locationSearching && (
+
+ Searching...
+
+ )}
+
+
+
+
+
+
+ {Object.entries(MOOD_CONFIG).map(([key, config]) => {
+ const Icon = config.icon
+ const active = mood === key
+ return (
+
+ )
+ })}
+
+
+
+
+
+
+ {Object.entries(WEATHER_CONFIG).map(([key, config]) => {
+ const Icon = config.icon
+ const active = weather === key
+ return (
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// ── Add Trip Dialog ──────────────────────────────────────────────────────
+
+function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
+ journeyId: number
+ existingTripIds: number[]
+ onClose: () => void
+ onAdded: () => void
+}) {
+ const { t } = useTranslation()
+ const [trips, setTrips] = useState<{ id: number; title: string; destination?: string; start_date?: string; end_date?: string }[]>([])
+ const [search, setSearch] = useState('')
+ const [adding, setAdding] = useState
(null)
+ const toast = useToast()
+
+ useEffect(() => {
+ journeyApi.availableTrips().then(d => setTrips(d.trips || [])).catch(() => {})
+ }, [])
+
+ const filtered = trips.filter(t => {
+ if (existingTripIds.includes(t.id)) return false
+ if (!search) return true
+ const q = search.toLowerCase()
+ return t.title.toLowerCase().includes(q) || (t.destination || '').toLowerCase().includes(q)
+ })
+
+ const handleAdd = async (tripId: number) => {
+ setAdding(tripId)
+ try {
+ await journeyApi.addTrip(journeyId, tripId)
+ toast.success(t('journey.trips.tripLinked'))
+ onAdded()
+ } catch {
+ toast.error(t('journey.trips.linkFailed'))
+ } finally {
+ setAdding(null)
+ }
+ }
+
+ return (
+
+
+
+
+
{t('journey.trips.linkTrip')}
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder={t('journey.trips.searchPlaceholder')}
+ className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
+ />
+
+
+
+ {filtered.length === 0 && (
+
{t('journey.trips.noTripsAvailable')}
+ )}
+ {filtered.map(t => (
+
+
+
+
{t.title}
+ {(t.destination || t.start_date) && (
+
+ {t.destination}{t.destination && t.start_date ? ' · ' : ''}{t.start_date}
+
+ )}
+
+
+
+ ))}
+
+
+
+
+ )
+}
+
+// ── Contributor Invite Dialog ─────────────────────────────────────────────
+
+function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvited }: {
+ journeyId: number
+ existingUserIds: number[]
+ onClose: () => void
+ onInvited: () => void
+}) {
+ const { t } = useTranslation()
+ const [users, setUsers] = useState<{ id: number; username: string; email: string; avatar?: string | null }[]>([])
+ const [search, setSearch] = useState('')
+ const [selectedUserId, setSelectedUserId] = useState(null)
+ const [role, setRole] = useState<'editor' | 'viewer'>('viewer')
+ const [sending, setSending] = useState(false)
+ const toast = useToast()
+
+ useEffect(() => {
+ authApi.listUsers().then(d => setUsers(d.users || [])).catch(() => {})
+ }, [])
+
+ const filtered = users.filter(u => {
+ if (existingUserIds.includes(u.id)) return false
+ if (!search) return true
+ const q = search.toLowerCase()
+ return u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
+ })
+
+ const handleInvite = async () => {
+ if (!selectedUserId) return
+ setSending(true)
+ try {
+ await journeyApi.addContributor(journeyId, selectedUserId, role)
+ toast.success(t('journey.contributors.added'))
+ onInvited()
+ } catch {
+ toast.error(t('journey.contributors.addFailed'))
+ } finally {
+ setSending(false)
+ }
+ }
+
+ return (
+
+
+
+
+
{t('journey.contributors.invite')}
+
+
+
+
+ {/* Search */}
+
+
+ setSearch(e.target.value)}
+ placeholder={t('journey.contributors.searchPlaceholder')}
+ className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
+ />
+
+
+ {/* User list */}
+
+ {filtered.length === 0 && (
+
{t('journey.contributors.noUsers')}
+ )}
+ {filtered.map(u => (
+
setSelectedUserId(u.id)}
+ className={`flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
+ selectedUserId === u.id
+ ? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-white'
+ : 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent'
+ }`}
+ >
+
+ {u.username[0].toUpperCase()}
+
+
+
{u.username}
+
{u.email}
+
+ {selectedUserId === u.id && (
+
+
+
+ )}
+
+ ))}
+
+
+ {/* Role selector */}
+
+
+
+ {(['viewer', 'editor'] as const).map(r => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// ── Journey Settings Dialog ───────────────────────────────────────────────
+
+// ── Journey Share Section ─────────────────────────────────────────────────
+
+function JourneyShareSection({ journeyId }: { journeyId: number }) {
+ const { t } = useTranslation()
+ const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null)
+ const [loading, setLoading] = useState(true)
+ const [copied, setCopied] = useState(false)
+ const toast = useToast()
+
+ useEffect(() => {
+ journeyApi.getShareLink(journeyId).then(d => setLink(d.link || null)).catch(() => {}).finally(() => setLoading(false))
+ }, [journeyId])
+
+ const createLink = async () => {
+ try {
+ const res = await journeyApi.createShareLink(journeyId, { share_timeline: true, share_gallery: true, share_map: true })
+ setLink({ token: res.token, share_timeline: true, share_gallery: true, share_map: true })
+ toast.success(t('journey.share.linkCreated'))
+ } catch { toast.error(t('journey.share.createFailed')) }
+ }
+
+ const togglePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => {
+ if (!link) return
+ const updated = { ...link, [key]: !link[key] }
+ setLink(updated)
+ try {
+ await journeyApi.createShareLink(journeyId, { share_timeline: updated.share_timeline, share_gallery: updated.share_gallery, share_map: updated.share_map })
+ } catch { setLink(link); toast.error(t('journey.share.updateFailed')) }
+ }
+
+ const deleteLink = async () => {
+ try {
+ await journeyApi.deleteShareLink(journeyId)
+ setLink(null)
+ toast.success(t('journey.share.linkDeleted'))
+ } catch { toast.error(t('journey.share.deleteFailed')) }
+ }
+
+ const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : ''
+
+ const copyLink = () => {
+ navigator.clipboard.writeText(shareUrl)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ if (loading) return null
+
+ return (
+
+
+
+ {!link ? (
+
+ ) : (
+
+ {/* URL + Copy */}
+
+
+ {shareUrl}
+
+
+
+ {/* Permission toggles */}
+
+ {[
+ { key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
+ { key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid },
+ { key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
+ ].map(({ key, label, icon: Icon }) => (
+
+ ))}
+
+
+ {/* Delete link */}
+
+
+ )}
+
+ )
+}
+
+function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
+ journey: JourneyDetail
+ onClose: () => void
+ onSaved: () => void
+ onOpenInvite: () => void
+}) {
+ const { t } = useTranslation()
+ const [title, setTitle] = useState(journey.title)
+ const [subtitle, setSubtitle] = useState(journey.subtitle || '')
+ const [saving, setSaving] = useState(false)
+ const [showAddTrip, setShowAddTrip] = useState(false)
+ const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
+ const coverRef = useRef(null)
+ const toast = useToast()
+ const navigate = useNavigate()
+ const { updateJourney, deleteJourney } = useJourneyStore()
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ await updateJourney(journey.id, { title, subtitle: subtitle || null })
+ onSaved()
+ } catch {
+ toast.error('Failed to save')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleCoverUpload = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+ const formData = new FormData()
+ formData.append('cover', file)
+ try {
+ await journeyApi.uploadCover(journey.id, formData)
+ toast.success('Cover updated')
+ onSaved()
+ } catch {
+ toast.error('Upload failed')
+ }
+ }
+
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+
+ const handleDelete = async () => {
+ try {
+ await deleteJourney(journey.id)
+ navigate('/journey')
+ } catch {
+ toast.error('Failed to delete')
+ }
+ }
+
+ return (
+
+
+
+
+
{t('journey.settings.title')}
+
+
+
+
+ {/* Cover Image */}
+
+
+
+
+
+
+ {/* Title */}
+
+
+ setTitle(e.target.value)}
+ className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
+ />
+
+
+ {/* Subtitle */}
+
+
+ setSubtitle(e.target.value)}
+ placeholder={t('journey.settings.subtitlePlaceholder')}
+ className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
+ />
+
+
+
+
+ {/* Synced Trips */}
+
+
+
+ {journey.trips.map((trip: any) => (
+
+
+
+
{trip.title}
+
{trip.place_count || 0} places
+
+
+
+ ))}
+ {journey.trips.length === 0 &&
No trips linked
}
+
+
+
+
+ {/* Contributors */}
+
+
+
+ {journey.contributors.map((c: any) => (
+
+
+ {(c.username || '?')[0].toUpperCase()}
+
+
{c.username}
+
{c.role}
+
+ ))}
+
+
+
+
+
+
+ {/* Public Share */}
+
+
+
+
+ {/* Footer */}
+
+
+
+
+
+
+
+ {/* Unlink Trip confirm */}
+
setUnlinkTarget(null)}
+ onConfirm={async () => {
+ if (!unlinkTarget) return
+ try {
+ await journeyApi.removeTrip(journey.id, unlinkTarget.trip_id)
+ toast.success('Trip unlinked')
+ setUnlinkTarget(null)
+ onSaved()
+ } catch {
+ toast.error('Failed to unlink trip')
+ }
+ }}
+ title="Unlink Trip"
+ message={`Unlink "${unlinkTarget?.title}"? All synced entries and photos from this trip will be permanently deleted. This cannot be undone.`}
+ confirmLabel="Unlink"
+ danger
+ />
+
+ {/* Add Trip */}
+ {showAddTrip && (
+ t.trip_id)}
+ onClose={() => setShowAddTrip(false)}
+ onAdded={() => { setShowAddTrip(false); onSaved() }}
+ />
+ )}
+
+ setShowDeleteConfirm(false)}
+ onConfirm={handleDelete}
+ title="Delete Journey"
+ message={`Delete "${journey.title}"? All entries and photos will be lost.`}
+ confirmLabel="Delete"
+ danger
+ />
+
+ )
+}
diff --git a/client/src/pages/JourneyPage.tsx b/client/src/pages/JourneyPage.tsx
new file mode 100644
index 00000000..790849f5
--- /dev/null
+++ b/client/src/pages/JourneyPage.tsx
@@ -0,0 +1,444 @@
+import { useEffect, useState, useMemo } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useJourneyStore } from '../store/journeyStore'
+import { journeyApi } from '../api/client'
+import Navbar from '../components/Layout/Navbar'
+import { useToast } from '../components/shared/Toast'
+import { useTranslation } from '../i18n'
+import {
+ Plus, Search, Sparkles, Calendar, MapPin, BookOpen, Camera,
+ Check, X, ChevronRight, RefreshCw, Users,
+} from 'lucide-react'
+import type { Journey } from '../store/journeyStore'
+
+const GRADIENTS = [
+ 'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
+ 'linear-gradient(135deg, #1E293B 0%, #7C3AED 50%, #F59E0B 100%)',
+ 'linear-gradient(135deg, #134E5E 0%, #71B280 100%)',
+ 'linear-gradient(135deg, #2D1B69 0%, #11998E 100%)',
+ 'linear-gradient(135deg, #4B134F 0%, #C94B4B 100%)',
+ 'linear-gradient(135deg, #373B44 0%, #4286F4 100%)',
+]
+
+function pickGradient(id: number): string {
+ return GRADIENTS[id % GRADIENTS.length]
+}
+
+function timeAgo(timestamp: number, t: (k: string, p?: any) => string): string {
+ const diff = Date.now() - timestamp
+ const hours = Math.floor(diff / 3600000)
+ if (hours < 1) return t('common.justNow')
+ if (hours < 24) return t('common.hoursAgo', { count: hours })
+ const days = Math.floor(hours / 24)
+ return t('common.daysAgo', { count: days })
+}
+
+export default function JourneyPage() {
+ const navigate = useNavigate()
+ const toast = useToast()
+ const { t } = useTranslation()
+ const { journeys, loading, loadJourneys, createJourney } = useJourneyStore()
+
+ const [showCreate, setShowCreate] = useState(false)
+ const [newTitle, setNewTitle] = useState('')
+ const [availableTrips, setAvailableTrips] = useState([])
+ const [selectedTripIds, setSelectedTripIds] = useState>(new Set())
+
+ // suggestion
+ const [suggestions, setSuggestions] = useState([])
+ const [dismissedSuggestions, setDismissedSuggestions] = useState>(new Set())
+
+ useEffect(() => {
+ loadJourneys()
+ journeyApi.suggestions().then(d => setSuggestions(d.trips || [])).catch(() => {})
+ }, [])
+
+ const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
+
+ const activeJourney = useMemo(() => {
+ return journeys.find(j => j.status === 'active') || null
+ }, [journeys])
+
+ const otherJourneys = useMemo(() => {
+ return journeys.filter(j => j.id !== activeJourney?.id)
+ }, [journeys, activeJourney])
+
+ const openCreateModal = async (preSelectedTripId?: number) => {
+ setShowCreate(true)
+ setNewTitle('')
+ const initial = new Set()
+ if (preSelectedTripId) initial.add(preSelectedTripId)
+ setSelectedTripIds(initial)
+ try {
+ const data = await journeyApi.availableTrips()
+ setAvailableTrips(data.trips || [])
+ } catch {}
+ }
+
+ const handleCreate = async () => {
+ if (!newTitle.trim()) return
+ try {
+ const j = await createJourney({
+ title: newTitle.trim(),
+ trip_ids: [...selectedTripIds],
+ })
+ setShowCreate(false)
+ navigate(`/journey/${j.id}`)
+ } catch {
+ toast.error(t('journey.createError'))
+ }
+ }
+
+ const totalPlaces = useMemo(() => {
+ return availableTrips.filter(t => selectedTripIds.has(t.id)).reduce((sum: number, t: any) => sum + (t.place_count || 0), 0)
+ }, [availableTrips, selectedTripIds])
+
+ return (
+
+
+
+
+
+ {/* Header — mobile: just a create button */}
+
+
+
+
+ {/* Header — desktop */}
+
+
+
{t('journey.title')}
+
{t("journey.frontpage.subtitle")}
+
+
+
+
+
+
+
+
+
+ {/* Suggestion banner */}
+ {activeSuggestion && (
+
+
+
+
+
+
+
+
+
+
{t("journey.frontpage.suggestionLabel")}
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Active Journey Hero */}
+ {activeJourney && (
+
+
+ {t("journey.frontpage.activeJourney")}
+
+
+ {t('journey.frontpage.updated', { time: timeAgo(activeJourney.updated_at, t) })}
+
+
+
+
navigate(`/journey/${activeJourney.id}`)}
+ className="relative rounded-3xl overflow-hidden cursor-pointer transition-all duration-300 hover:-translate-y-1 hover:shadow-xl h-[340px] md:h-[400px]"
+ style={{ background: pickGradient(activeJourney.id) }}
+ >
+ {/* Cover image */}
+ {activeJourney.cover_image && (
+
+

+
+
+ )}
+
+ {/* Gradient overlays */}
+
+
+
+
+ {/* Top badges */}
+
+
+
+
+ {t('journey.frontpage.live')}
+
+
+
+ {t('journey.frontpage.synced')}
+
+
+
+
+ {/* Middle — title */}
+
+ {activeJourney.subtitle && (
+
{activeJourney.subtitle}
+ )}
+
+ {activeJourney.title}
+
+
+
+ {/* Bottom stats */}
+
+
+ {[
+ { val: (activeJourney as any).entry_count ?? '--', label: t("journey.stats.entries") },
+ { val: (activeJourney as any).photo_count ?? '--', label: t("journey.stats.photos") },
+ { val: (activeJourney as any).city_count ?? '--', label: t("journey.stats.cities") },
+ ].map(s => (
+
+ {s.val}
+ {s.label}
+
+ ))}
+
+
+ {t('journey.frontpage.continueWriting')}
+
+
+
+
+
+ )}
+
+ {/* All Journeys */}
+
+ {t("journey.frontpage.allJourneys")}
+ {journeys.length} {t('journey.frontpage.journeys')}
+
+
+ {loading && journeys.length === 0 ? (
+
+ ) : (
+
+ {otherJourneys.map(j => (
+
navigate(`/journey/${j.id}`)} />
+ ))}
+
+ {/* Create card */}
+
+
+ )}
+
+
+
+
+ {/* Create Modal */}
+ {showCreate && (
+
+
+
+ {/* Header */}
+
+
{t("journey.frontpage.createJourney")}
+
{t('journey.frontpage.createNewSub')}
+
+
+ {/* Body */}
+
+
+
setNewTitle(e.target.value)}
+ placeholder={t('journey.frontpage.namePlaceholder')}
+ className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-900 dark:focus:border-zinc-400 focus:outline-none mb-5"
+ />
+
+
+
+ {availableTrips.map(trip => {
+ const selected = selectedTripIds.has(trip.id)
+ const status = trip.end_date && trip.end_date < new Date().toISOString().split('T')[0]
+ ? 'completed'
+ : trip.start_date && trip.start_date <= new Date().toISOString().split('T')[0]
+ ? 'active'
+ : 'upcoming'
+ const statusColors: Record
= {
+ completed: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
+ active: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
+ upcoming: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
+ }
+
+ return (
+ {
+ setSelectedTripIds(prev => {
+ const next = new Set(prev)
+ if (next.has(trip.id)) next.delete(trip.id)
+ else next.add(trip.id)
+ return next
+ })
+ }}
+ className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${
+ selected
+ ? 'border-zinc-900 dark:border-zinc-400 bg-zinc-50 dark:bg-zinc-800'
+ : 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500'
+ }`}
+ >
+
+ {selected && }
+
+
+
+
{trip.title}
+
+ {trip.start_date ? Math.ceil((new Date(trip.end_date || trip.start_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1 : '?'} {t('journey.stats.days').toLowerCase()}
+ {trip.place_count || 0} {t("journey.frontpage.places")}
+
+
+
+ {t(`journey.status.${status}`)}
+
+
+ )
+ })}
+
+
+
+ {/* Footer */}
+
+
+ {selectedTripIds.size} {t('journey.frontpage.tripsSelected')}{t('journey.frontpage.trips')}
+ {selectedTripIds.size > 0 && <> · {totalPlaces} {t('journey.frontpage.placesImported')}{t('journey.frontpage.places')}>}
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; city_count?: number }; onClick: () => void }) {
+ const { t } = useTranslation()
+ const j = journey
+ const entryCount = j.entry_count ?? 0
+ const photoCount = j.photo_count ?? 0
+ const cityCount = j.city_count ?? 0
+
+ return (
+
+ {/* Cover */}
+
+ {j.cover_image && (
+ <>
+

+
+ >
+ )}
+
+
+ {/* Top overlay */}
+
+
+
+ {new Date(j.created_at).getFullYear()}
+
+
+
+
+
+ {/* Body */}
+
+
{j.title}
+ {j.subtitle && (
+
{j.subtitle}
+ )}
+ {j.status === 'draft' && (
+
{t('journey.status.draft')}
+ )}
+
+
+ {[
+ { val: entryCount, label: t('journey.stats.entries') },
+ { val: photoCount, label: t('journey.stats.photos') },
+ { val: cityCount, label: t('journey.stats.cities') },
+ ].map(s => (
+
+ 0 ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`}>
+ {s.val > 0 ? s.val : '--'}
+
+ {s.label}
+
+ ))}
+
+
+
+ )
+}
diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx
new file mode 100644
index 00000000..8fa60d9c
--- /dev/null
+++ b/client/src/pages/JourneyPublicPage.tsx
@@ -0,0 +1,347 @@
+import { useEffect, useState, useMemo } from 'react'
+import { useParams } from 'react-router-dom'
+import { journeyApi } from '../api/client'
+import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
+import { useSettingsStore } from '../store/settingsStore'
+import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
+import JourneyMap from '../components/Journey/JourneyMap'
+import JournalBody from '../components/Journey/JournalBody'
+import PhotoLightbox from '../components/Journey/PhotoLightbox'
+
+interface PublicEntry {
+ id: number
+ title?: string | null
+ story?: string | null
+ entry_date: string
+ entry_time?: string | null
+ location_name?: string | null
+ location_lat?: number | null
+ location_lng?: number | null
+ mood?: string | null
+ weather?: string | null
+ pros_cons?: { pros: string[]; cons: string[] } | null
+ photos: PublicPhoto[]
+}
+
+interface PublicPhoto {
+ id: number
+ entry_id: number
+ provider: string
+ asset_id?: string | null
+ owner_id?: number | null
+ file_path?: string | null
+ caption?: string | null
+}
+
+function photoUrl(p: PublicPhoto, shareToken: string): string {
+ if (p.provider === 'local') return `/api/public/journey/${shareToken}/photo/local/${encodeURIComponent(p.file_path || '')}/0/original`
+ return `/api/public/journey/${shareToken}/photo/${p.provider}/${p.asset_id}/${p.owner_id}/original`
+}
+
+function formatDate(d: string): { weekday: string; month: string; day: number } {
+ const date = new Date(d + 'T00:00:00')
+ return {
+ weekday: date.toLocaleDateString('en', { weekday: 'long' }),
+ month: date.toLocaleDateString('en', { month: 'long' }),
+ day: date.getDate(),
+ }
+}
+
+function groupByDate(entries: PublicEntry[]): Map {
+ const groups = new Map()
+ for (const e of entries) {
+ const d = e.entry_date
+ if (!groups.has(d)) groups.set(d, [])
+ groups.get(d)!.push(e)
+ }
+ return groups
+}
+
+export default function JourneyPublicPage() {
+ const { token } = useParams()
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(false)
+ const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
+ const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
+ const { t } = useTranslation()
+ const [showLangPicker, setShowLangPicker] = useState(false)
+ const locale = useSettingsStore(s => s.settings.language) || 'en'
+
+ useEffect(() => {
+ if (!token) return
+ journeyApi.getPublicJourney(token)
+ .then(d => setData(d))
+ .catch(() => setError(true))
+ .finally(() => setLoading(false))
+ }, [token])
+
+ const entries = (data?.entries || []) as PublicEntry[]
+ const perms = data?.permissions || {}
+ const journey = data?.journey || {}
+ const stats = data?.stats || {}
+
+ const groupedEntries = useMemo(() => groupByDate(entries), [entries])
+ const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
+ const mapEntries = useMemo(() => entries.filter(e => e.location_lat && e.location_lng), [entries])
+ const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
+
+ // Set default view based on permissions
+ useEffect(() => {
+ if (!perms.share_timeline && perms.share_gallery) setView('gallery')
+ else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
+ }, [perms])
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error || !data) {
+ return (
+
+
+
{t('journey.public.notFound')}
+
{t('journey.public.notFoundMessage')}
+
+
+ )
+ }
+
+ const availableViews = [
+ perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
+ perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
+ perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
+ ].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
+
+ return (
+
+ {/* Hero */}
+
+ {/* Cover image background */}
+ {journey.cover_image && (
+
+ )}
+ {/* Decorative circles */}
+
+
+
+ {/* Language picker */}
+
+
+ {showLangPicker && (
+
+ {SUPPORTED_LANGUAGES.map(lang => (
+
+ ))}
+
+ )}
+
+
+ {/* Logo */}
+
+

+
+
+
{t('journey.public.tagline')}
+
+
{journey.title}
+
+ {journey.subtitle && (
+
{journey.subtitle}
+ )}
+
+ {/* Stats pill */}
+
+ {stats.entries} {t('journey.stats.entries')}
+ ·
+ {stats.photos} {t('journey.stats.photos')}
+ ·
+ {stats.cities} {t('journey.stats.places')}
+
+
+
{t('journey.public.readOnly')}
+
+
+ {/* Content */}
+
+
+ {/* View tabs */}
+ {availableViews.length > 1 && (
+
+ {availableViews.map(v => (
+
+ ))}
+
+ )}
+
+ {/* Timeline */}
+ {view === 'timeline' && perms.share_timeline && (
+
+ {sortedDates.map(date => {
+ const dayEntries = groupedEntries.get(date)!
+ const fd = formatDate(date)
+ return (
+
+
+
{fd.day}
+
+
{fd.weekday}
+
{fd.month} {fd.day}
+
+
+
+ {dayEntries.map(entry => (
+
+ {entry.photos.length > 0 && (
+
+

setLightbox({ photos: entry.photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: 0 })}
+ />
+ {entry.photos.length > 1 && (
+
+ +{entry.photos.length - 1}
+
+ )}
+ {entry.title && (
+
+
{entry.title}
+
+ )}
+
+ )}
+
+ {!entry.photos.length && entry.title && (
+
{entry.title}
+ )}
+ {entry.location_name && (
+
+ {entry.location_name}
+
+ )}
+ {entry.story && (
+
+
+
+ )}
+ {entry.pros_cons && ((entry.pros_cons.pros?.length ?? 0) > 0 || (entry.pros_cons.cons?.length ?? 0) > 0) && (
+
+ {(entry.pros_cons.pros?.length ?? 0) > 0 && (
+
+
{t('journey.editor.pros')}
+ {entry.pros_cons.pros!.map((p, i) => (
+
+ {p}
+
+ ))}
+
+ )}
+ {(entry.pros_cons.cons?.length ?? 0) > 0 && (
+
+
{t('journey.editor.cons')}
+ {entry.pros_cons.cons!.map((c, i) => (
+
+ {c}
+
+ ))}
+
+ )}
+
+ )}
+
+
+ ))}
+
+
+ )
+ })}
+
+ )}
+
+ {/* Gallery */}
+ {view === 'gallery' && perms.share_gallery && (
+
+ {allPhotos.map(({ photo }, idx) => (
+
setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: idx })}
+ >
+

+
+ ))}
+
+ )}
+
+ {/* Map */}
+ {view === 'map' && perms.share_map && (
+
+ ({
+ id: String(e.id),
+ lat: e.location_lat!,
+ lng: e.location_lng!,
+ title: e.title || '',
+ mood: e.mood,
+ created_at: e.entry_date,
+ entry_date: e.entry_date,
+ })) as any}
+ height={500}
+ />
+
+ )}
+
+
+ {/* Powered by */}
+
+
+

+
{t('journey.public.sharedVia')} TREK
+
+
+ Made with
♥ by Maurice ·
GitHub
+
+
+
+ {/* Lightbox */}
+ {lightbox && (
+
setLightbox(null)}
+ />
+ )}
+
+ )
+}
diff --git a/client/src/pages/TripPlannerPage.test.tsx b/client/src/pages/TripPlannerPage.test.tsx
index 54a7d99d..78096249 100644
--- a/client/src/pages/TripPlannerPage.test.tsx
+++ b/client/src/pages/TripPlannerPage.test.tsx
@@ -592,32 +592,7 @@ describe('TripPlannerPage', () => {
});
});
- describe('FE-PAGE-PLANNER-018: Memories tab renders MemoriesPanel', () => {
- it('shows MemoriesPanel after clicking the Photos tab with a photo_provider addon enabled', async () => {
- server.use(
- http.get('/api/addons', () =>
- HttpResponse.json({ addons: [{ id: 'google_photos', type: 'photo_provider' }] })
- )
- );
-
- vi.useFakeTimers();
-
- seedTripStore({ id: 42 });
-
- renderPlannerPage(42);
-
- act(() => { vi.runAllTimers(); });
-
- vi.useRealTimers();
-
- const photosTab = await screen.findByTitle('Photos');
- fireEvent.click(photosTab);
-
- await waitFor(() => {
- expect(screen.getByTestId('memories-panel')).toBeInTheDocument();
- });
- });
- });
+ // FE-PAGE-PLANNER-018: Removed — MemoriesPanel moved to Journey addon
describe('FE-PAGE-PLANNER-019: Todo subtab in ListsContainer', () => {
it('shows TodoListPanel after switching to the Todo subtab inside Lists', async () => {
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index e75d1bdb..1002531e 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -14,7 +14,7 @@ import PlaceFormModal from '../components/Planner/PlaceFormModal'
import TripFormModal from '../components/Trips/TripFormModal'
import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal'
-import MemoriesPanel from '../components/Memories/MemoriesPanel'
+// MemoriesPanel moved to Journey addon
import ReservationsPanel from '../components/Planner/ReservationsPanel'
import PackingListPanel from '../components/Packing/PackingListPanel'
import TodoListPanel from '../components/Todo/TodoListPanel'
@@ -23,7 +23,7 @@ import BudgetPanel from '../components/Budget/BudgetPanel'
import CollabPanel from '../components/Collab/CollabPanel'
import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
-import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Camera, Users } from 'lucide-react'
+import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users } from 'lucide-react'
import { useTranslation } from '../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
import ConfirmDialog from '../components/shared/ConfirmDialog'
@@ -97,7 +97,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
toast.info(t('undo.done', { action: label ?? '' }))
}, [undo, lastActionLabel, toast])
- const [enabledAddons, setEnabledAddons] = useState>({ packing: true, budget: true, documents: true })
+ const [enabledAddons, setEnabledAddons] = useState>({ packing: true, budget: true, documents: true, collab: false })
const [tripAccommodations, setTripAccommodations] = useState([])
const [allowedFileTypes, setAllowedFileTypes] = useState(null)
const [tripMembers, setTripMembers] = useState([])
@@ -113,9 +113,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
addonsApi.enabled().then(data => {
const map = {}
data.addons.forEach(a => { map[a.id] = true })
- // Check if any photo provider is enabled (for memories tab to show)
- const hasPhotoProviders = data.addons.some(a => a.type === 'photo_provider')
- setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: hasPhotoProviders })
+ setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
}).catch(() => {})
authApi.getAppConfig().then(config => {
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
@@ -128,7 +126,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
...(enabledAddons.packing ? [{ id: 'listen', label: t('trip.tabs.lists'), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []),
- ...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title'), icon: Camera }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []),
]
@@ -890,7 +887,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left'
- ?
{ handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
+ ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
: { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
}
@@ -946,12 +943,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
- {activeTab === 'memories' && (
-
-
-
- )}
-
{activeTab === 'collab' && (
diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts
new file mode 100644
index 00000000..c643117d
--- /dev/null
+++ b/client/src/store/journeyStore.ts
@@ -0,0 +1,213 @@
+import { create } from 'zustand'
+import { journeyApi } from '../api/client'
+
+export interface Journey {
+ id: number
+ user_id: number
+ title: string
+ subtitle?: string | null
+ cover_gradient?: string | null
+ cover_image?: string | null
+ status: 'draft' | 'active' | 'completed'
+ created_at: number
+ updated_at: number
+}
+
+export interface JourneyEntry {
+ id: number
+ journey_id: number
+ source_trip_id?: number | null
+ source_place_id?: number | null
+ source_trip_name?: string | null
+ author_id: number
+ type: 'entry' | 'checkin' | 'skeleton'
+ title?: string | null
+ story?: string | null
+ entry_date: string
+ entry_time?: string | null
+ location_name?: string | null
+ location_lat?: number | null
+ location_lng?: number | null
+ mood?: string | null
+ weather?: string | null
+ tags?: string[]
+ pros_cons?: { pros: string[]; cons: string[] } | null
+ visibility: string
+ sort_order: number
+ photos: JourneyPhoto[]
+ created_at: number
+ updated_at: number
+}
+
+export interface JourneyPhoto {
+ id: number
+ entry_id: number
+ provider: 'local' | 'immich' | 'synologyphotos'
+ asset_id?: string | null
+ owner_id?: number | null
+ file_path?: string | null
+ thumbnail_path?: string | null
+ caption?: string | null
+ sort_order: number
+ width?: number | null
+ height?: number | null
+ shared: number
+ created_at: number
+}
+
+export interface JourneyTrip {
+ trip_id: number
+ added_at: number
+ title: string
+ start_date?: string | null
+ end_date?: string | null
+ cover_image?: string | null
+ currency?: string
+ place_count: number
+}
+
+export interface JourneyContributor {
+ journey_id: number
+ user_id: number
+ role: 'owner' | 'editor' | 'viewer'
+ added_at: number
+ username: string
+ avatar?: string | null
+}
+
+export interface JourneyDetail extends Journey {
+ entries: JourneyEntry[]
+ trips: JourneyTrip[]
+ contributors: JourneyContributor[]
+ stats: { entries: number; photos: number; cities: number }
+}
+
+interface JourneyState {
+ journeys: Journey[]
+ current: JourneyDetail | null
+ loading: boolean
+
+ loadJourneys: () => Promise
+ loadJourney: (id: number) => Promise
+ createJourney: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => Promise
+ updateJourney: (id: number, data: Record) => Promise
+ deleteJourney: (id: number) => Promise
+
+ createEntry: (journeyId: number, data: Record) => Promise
+ updateEntry: (entryId: number, data: Record) => Promise
+ deleteEntry: (entryId: number) => Promise
+
+ uploadPhotos: (entryId: number, formData: FormData) => Promise
+ deletePhoto: (photoId: number) => Promise
+
+ clear: () => void
+}
+
+export const useJourneyStore = create((set, get) => ({
+ journeys: [],
+ current: null,
+ loading: false,
+
+ loadJourneys: async () => {
+ set({ loading: true })
+ try {
+ const data = await journeyApi.list()
+ set({ journeys: data.journeys || [] })
+ } finally {
+ set({ loading: false })
+ }
+ },
+
+ loadJourney: async (id) => {
+ set({ loading: true })
+ try {
+ const data = await journeyApi.get(id)
+ set({ current: data })
+ } finally {
+ set({ loading: false })
+ }
+ },
+
+ createJourney: async (data) => {
+ const journey = await journeyApi.create(data)
+ set(s => ({ journeys: [journey, ...s.journeys] }))
+ return journey
+ },
+
+ updateJourney: async (id, data) => {
+ const updated = await journeyApi.update(id, data)
+ set(s => ({
+ journeys: s.journeys.map(j => j.id === id ? { ...j, ...updated } : j),
+ current: s.current?.id === id ? { ...s.current, ...updated } : s.current,
+ }))
+ },
+
+ deleteJourney: async (id) => {
+ await journeyApi.delete(id)
+ set(s => ({
+ journeys: s.journeys.filter(j => j.id !== id),
+ current: s.current?.id === id ? null : s.current,
+ }))
+ },
+
+ createEntry: async (journeyId, data) => {
+ const entry = await journeyApi.createEntry(journeyId, data)
+ entry.photos = entry.photos || []
+ set(s => {
+ if (s.current?.id !== journeyId) return s
+ return { current: { ...s.current, entries: [...s.current.entries, entry] } }
+ })
+ return entry
+ },
+
+ updateEntry: async (entryId, data) => {
+ const updated = await journeyApi.updateEntry(entryId, data)
+ set(s => {
+ if (!s.current) return s
+ return { current: { ...s.current, entries: s.current.entries.map(e => e.id === entryId ? { ...e, ...updated } : e) } }
+ })
+ },
+
+ deleteEntry: async (entryId) => {
+ await journeyApi.deleteEntry(entryId)
+ set(s => {
+ if (!s.current) return s
+ return { current: { ...s.current, entries: s.current.entries.filter(e => e.id !== entryId) } }
+ })
+ },
+
+ uploadPhotos: async (entryId, formData) => {
+ const data = await journeyApi.uploadPhotos(entryId, formData)
+ const photos = data.photos || []
+ set(s => {
+ if (!s.current) return s
+ return {
+ current: {
+ ...s.current,
+ entries: s.current.entries.map(e =>
+ e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
+ ),
+ },
+ }
+ })
+ return photos
+ },
+
+ deletePhoto: async (photoId) => {
+ await journeyApi.deletePhoto(photoId)
+ set(s => {
+ if (!s.current) return s
+ return {
+ current: {
+ ...s.current,
+ entries: s.current.entries.map(e => ({
+ ...e,
+ photos: (e.photos || []).filter(p => p.id !== photoId),
+ })),
+ },
+ }
+ })
+ },
+
+ clear: () => set({ journeys: [], current: null, loading: false }),
+}))
diff --git a/server/package-lock.json b/server/package-lock.json
index 9be4a5c5..b3460c0b 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -21,7 +21,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
- "nodemailer": "^8.0.4",
+ "nodemailer": "^8.0.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"tsx": "^4.21.0",
diff --git a/server/package.json b/server/package.json
index 49f2ac2c..e613d842 100644
--- a/server/package.json
+++ b/server/package.json
@@ -27,7 +27,7 @@
"multer": "^2.1.1",
"node-cron": "^4.2.1",
"undici": "^7.0.0",
- "nodemailer": "^8.0.4",
+ "nodemailer": "^8.0.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"tsx": "^4.21.0",
@@ -37,6 +37,10 @@
"ws": "^8.19.0",
"zod": "^4.3.6"
},
+ "overrides": {
+ "hono": "^4.12.12",
+ "@hono/node-server": "^1.19.13"
+ },
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
diff --git a/server/src/app.ts b/server/src/app.ts
index 53c8fe87..3bf2336a 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -38,6 +38,8 @@ import atlasRoutes from './routes/atlas';
import memoriesRoutes from './routes/memories/unified';
import notificationRoutes from './routes/notifications';
import shareRoutes from './routes/share';
+import journeyRoutes from './routes/journey';
+import journeyPublicRoutes from './routes/journeyPublic';
import { mcpHandler } from './mcp';
import { Addon } from './types';
import { getPhotoProviderConfig } from './services/memories/helpersService';
@@ -143,9 +145,10 @@ export function createApp(): express.Application {
});
}
- // Static: avatars and covers are public
+ // Static: avatars, covers, and journey photos
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
+ app.use('/uploads/journey', express.static(path.join(__dirname, '../uploads/journey')));
// Photos require auth or valid share token
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
@@ -259,6 +262,8 @@ export function createApp(): express.Application {
// Addon routes
app.use('/api/addons/vacay', vacayRoutes);
app.use('/api/addons/atlas', atlasRoutes);
+ app.use('/api/journeys', journeyRoutes);
+ app.use('/api/public/journey', journeyPublicRoutes);
app.use('/api/integrations/memories', memoriesRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes);
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index 56acf672..78a1e761 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -885,6 +885,438 @@ function runMigrations(db: Database.Database): void {
ins.run(r.trip_id, r.category, idx++);
}
},
+ // Migration 84: Journey addon — trip tracking & travel journal
+ () => {
+ // Register addon (disabled by default — opt-in)
+ db.prepare(`
+ INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order)
+ VALUES ('journey', 'Journey', 'Trip tracking & travel journal — check-ins, photos, daily stories', 'global', 'Compass', 0, '{}', 35)
+ `).run();
+
+ // Core journey table
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS journeys (
+ id TEXT PRIMARY KEY,
+ trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ description TEXT,
+ cover_image TEXT,
+ status TEXT NOT NULL DEFAULT 'draft',
+ started_at TEXT,
+ ended_at TEXT,
+ is_public INTEGER NOT NULL DEFAULT 0,
+ public_token TEXT UNIQUE,
+ settings TEXT DEFAULT '{}',
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ )
+ `);
+
+ // Check-ins — visited locations
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS journey_checkins (
+ id TEXT PRIMARY KEY,
+ journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
+ place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
+ name TEXT NOT NULL,
+ lat REAL,
+ lng REAL,
+ address TEXT,
+ country_code TEXT,
+ notes TEXT,
+ checked_in_at TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'manual',
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ )
+ `);
+
+ // Journal entries — daily stories
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS journey_entries (
+ id TEXT PRIMARY KEY,
+ journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
+ checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL,
+ entry_date TEXT NOT NULL,
+ title TEXT,
+ body TEXT,
+ mood TEXT,
+ weather TEXT,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ )
+ `);
+
+ // Photos — local uploads + provider references (Immich/Synology)
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS journey_photos (
+ id TEXT PRIMARY KEY,
+ journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
+ checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL,
+ entry_id TEXT REFERENCES journey_entries(id) ON DELETE SET NULL,
+ storage_type TEXT NOT NULL DEFAULT 'local',
+ asset_id TEXT,
+ file_path TEXT,
+ thumbnail_path TEXT,
+ original_name TEXT,
+ mime_type TEXT,
+ size_bytes INTEGER,
+ caption TEXT,
+ taken_at TEXT,
+ lat REAL,
+ lng REAL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ )
+ `);
+
+ // GPS trail points (Dawarich integration)
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS journey_location_trail (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
+ lat REAL NOT NULL,
+ lng REAL NOT NULL,
+ altitude REAL,
+ accuracy REAL,
+ recorded_at TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'dawarich'
+ )
+ `);
+
+ // Indexes
+ db.exec(`
+ CREATE INDEX IF NOT EXISTS idx_journeys_user ON journeys(user_id);
+ CREATE INDEX IF NOT EXISTS idx_journeys_trip ON journeys(trip_id);
+ CREATE INDEX IF NOT EXISTS idx_journeys_public_token ON journeys(public_token);
+ CREATE INDEX IF NOT EXISTS idx_journey_checkins_journey ON journey_checkins(journey_id, checked_in_at);
+ CREATE INDEX IF NOT EXISTS idx_journey_entries_journey_date ON journey_entries(journey_id, entry_date);
+ CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id);
+ CREATE INDEX IF NOT EXISTS idx_journey_photos_checkin ON journey_photos(checkin_id);
+ CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id);
+ CREATE INDEX IF NOT EXISTS idx_journey_trail_journey_time ON journey_location_trail(journey_id, recorded_at);
+ `);
+ },
+ // Migration 85: Journal — richer entry fields for magazine-style design
+ () => {
+ // Highlight tags (JSON array), visibility control, hero photo, color accent
+ try { db.exec('ALTER TABLE journey_entries ADD COLUMN highlight_tags TEXT'); } catch {}
+ try { db.exec("ALTER TABLE journey_entries ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'"); } catch {}
+ try { db.exec('ALTER TABLE journey_entries ADD COLUMN hero_photo_id TEXT'); } catch {}
+ try { db.exec('ALTER TABLE journey_entries ADD COLUMN color_accent TEXT'); } catch {}
+ try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_name TEXT'); } catch {}
+ try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL'); } catch {}
+ try { db.exec('ALTER TABLE journey_entries ADD COLUMN lat REAL'); } catch {}
+ try { db.exec('ALTER TABLE journey_entries ADD COLUMN lng REAL'); } catch {}
+
+ // Check-in: allow a single cover photo reference
+ try { db.exec('ALTER TABLE journey_checkins ADD COLUMN photo_id TEXT'); } catch {}
+
+ // Photos: add caption edit timestamp for gallery ordering
+ try { db.exec('ALTER TABLE journey_photos ADD COLUMN width INTEGER'); } catch {}
+ try { db.exec('ALTER TABLE journey_photos ADD COLUMN height INTEGER'); } catch {}
+ },
+ // Migration 86: Journey multi-trip support + sharing/collaboration
+ () => {
+ // Junction table: journey can include multiple trips
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS journey_trips (
+ journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
+ trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
+ PRIMARY KEY (journey_id, trip_id)
+ )
+ `);
+ db.exec('CREATE INDEX IF NOT EXISTS idx_journey_trips_journey ON journey_trips(journey_id)');
+
+ // Sharing: invite users to a journey
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS journey_members (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role TEXT NOT NULL DEFAULT 'viewer',
+ invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
+ UNIQUE(journey_id, user_id)
+ )
+ `);
+ db.exec('CREATE INDEX IF NOT EXISTS idx_journey_members_user ON journey_members(user_id)');
+
+ // author tracking on entries and checkins
+ try { db.exec('ALTER TABLE journey_entries ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
+ try { db.exec('ALTER TABLE journey_checkins ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
+ },
+ // Migration 87: Journey rebuild — new schema with trip sync
+ () => {
+ // Migrate existing data from old tables into backup, then rebuild
+ const hasOldJourneys = db.prepare(
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='journeys'"
+ ).get();
+
+ let oldJourneys: any[] = [];
+ let oldEntries: any[] = [];
+ let oldPhotos: any[] = [];
+
+ if (hasOldJourneys) {
+ // Save existing data before dropping
+ try { oldJourneys = db.prepare('SELECT * FROM journeys').all(); } catch {}
+ try { oldEntries = db.prepare('SELECT * FROM journey_entries').all(); } catch {}
+ try { oldPhotos = db.prepare('SELECT * FROM journey_photos').all(); } catch {}
+
+ // Drop all old journey tables
+ db.exec('DROP TABLE IF EXISTS journey_location_trail');
+ db.exec('DROP TABLE IF EXISTS journey_photos');
+ db.exec('DROP TABLE IF EXISTS journey_entries');
+ db.exec('DROP TABLE IF EXISTS journey_checkins');
+ db.exec('DROP TABLE IF EXISTS journey_members');
+ db.exec('DROP TABLE IF EXISTS journey_trips');
+ db.exec('DROP TABLE IF EXISTS journeys');
+ }
+
+ // New schema
+ db.exec(`
+ CREATE TABLE journeys (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ title TEXT NOT NULL,
+ subtitle TEXT,
+ cover_gradient TEXT,
+ status TEXT DEFAULT 'draft',
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ `);
+
+ db.exec(`
+ CREATE TABLE journey_trips (
+ journey_id INTEGER NOT NULL,
+ trip_id INTEGER NOT NULL,
+ added_at INTEGER NOT NULL,
+ PRIMARY KEY (journey_id, trip_id),
+ FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
+ FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE
+ )
+ `);
+
+ db.exec(`
+ CREATE TABLE journey_entries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ journey_id INTEGER NOT NULL,
+ source_trip_id INTEGER,
+ source_place_id INTEGER,
+ author_id INTEGER NOT NULL,
+ type TEXT NOT NULL,
+ title TEXT,
+ story TEXT,
+ entry_date TEXT NOT NULL,
+ entry_time TEXT,
+ location_name TEXT,
+ location_lat REAL,
+ location_lng REAL,
+ mood TEXT,
+ weather TEXT,
+ tags TEXT,
+ visibility TEXT DEFAULT 'private',
+ sort_order INTEGER DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
+ FOREIGN KEY (source_trip_id) REFERENCES trips(id) ON DELETE SET NULL,
+ FOREIGN KEY (source_place_id) REFERENCES places(id) ON DELETE SET NULL,
+ FOREIGN KEY (author_id) REFERENCES users(id)
+ )
+ `);
+
+ db.exec(`
+ CREATE TABLE journey_photos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ entry_id INTEGER NOT NULL,
+ file_path TEXT NOT NULL,
+ thumbnail_path TEXT,
+ caption TEXT,
+ sort_order INTEGER DEFAULT 0,
+ width INTEGER,
+ height INTEGER,
+ created_at INTEGER NOT NULL,
+ FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
+ )
+ `);
+
+ db.exec(`
+ CREATE TABLE journey_contributors (
+ journey_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ role TEXT NOT NULL,
+ added_at INTEGER NOT NULL,
+ PRIMARY KEY (journey_id, user_id),
+ FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ `);
+
+ // Indexes
+ db.exec(`
+ CREATE INDEX idx_journeys_user ON journeys(user_id);
+ CREATE INDEX idx_journey_entries_journey ON journey_entries(journey_id, entry_date);
+ CREATE INDEX idx_journey_entries_source ON journey_entries(source_place_id);
+ CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id);
+ CREATE INDEX idx_journey_trips_journey ON journey_trips(journey_id);
+ CREATE INDEX idx_journey_contributors_user ON journey_contributors(user_id);
+ `);
+
+ // Re-import old data if it existed
+ if (oldJourneys.length > 0) {
+ const ts = Date.now();
+ const journeyIdMap = new Map(); // old TEXT id -> new INTEGER id
+
+ for (const j of oldJourneys) {
+ const res = db.prepare(`
+ INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `).run(
+ j.user_id,
+ j.title || 'Untitled Journey',
+ j.description || null,
+ j.status || 'draft',
+ j.created_at ? new Date(j.created_at).getTime() : ts,
+ j.updated_at ? new Date(j.updated_at).getTime() : ts
+ );
+ journeyIdMap.set(j.id, Number(res.lastInsertRowid));
+
+ // Add owner as contributor
+ db.prepare(`
+ INSERT OR IGNORE INTO journey_contributors (journey_id, user_id, role, added_at)
+ VALUES (?, ?, 'owner', ?)
+ `).run(Number(res.lastInsertRowid), j.user_id, ts);
+
+ // Link trip if old journey had one
+ if (j.trip_id) {
+ try {
+ db.prepare(`
+ INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at)
+ VALUES (?, ?, ?)
+ `).run(Number(res.lastInsertRowid), j.trip_id, ts);
+ } catch {}
+ }
+ }
+
+ // Migrate entries
+ const entryIdMap = new Map();
+ for (const e of oldEntries) {
+ const newJourneyId = journeyIdMap.get(e.journey_id);
+ if (!newJourneyId) continue;
+
+ const res = db.prepare(`
+ INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, visibility, sort_order, created_at, updated_at)
+ VALUES (?, ?, 'entry', ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ newJourneyId,
+ e.user_id || oldJourneys.find((j: any) => j.id === e.journey_id)?.user_id || 1,
+ e.title || null,
+ e.body || null,
+ e.entry_date || new Date().toISOString().split('T')[0],
+ e.place_name || null,
+ e.lat || null,
+ e.lng || null,
+ e.mood || null,
+ e.weather || null,
+ e.visibility || 'private',
+ e.sort_order || 0,
+ e.created_at ? new Date(e.created_at).getTime() : ts,
+ e.updated_at ? new Date(e.updated_at).getTime() : ts
+ );
+ entryIdMap.set(e.id, Number(res.lastInsertRowid));
+ }
+
+ // Migrate photos
+ for (const p of oldPhotos) {
+ const newEntryId = p.entry_id ? entryIdMap.get(p.entry_id) : null;
+ if (!newEntryId || !p.file_path) continue;
+
+ db.prepare(`
+ INSERT INTO journey_photos (entry_id, file_path, thumbnail_path, caption, sort_order, width, height, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ newEntryId,
+ p.file_path,
+ p.thumbnail_path || null,
+ p.caption || null,
+ p.sort_order || 0,
+ p.width || null,
+ p.height || null,
+ p.created_at ? new Date(p.created_at).getTime() : ts
+ );
+ }
+
+ console.log(`[DB] Journey migration: imported ${journeyIdMap.size} journeys, ${entryIdMap.size} entries, photos migrated`);
+ }
+ },
+ // Migration 88: Journey photos — provider support (Immich/Synology)
+ () => {
+ try { db.exec("ALTER TABLE journey_photos ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'"); } catch {}
+ try { db.exec('ALTER TABLE journey_photos ADD COLUMN asset_id TEXT'); } catch {}
+ try { db.exec('ALTER TABLE journey_photos ADD COLUMN owner_id INTEGER REFERENCES users(id)'); } catch {}
+ try { db.exec('ALTER TABLE journey_photos ADD COLUMN shared INTEGER NOT NULL DEFAULT 1'); } catch {}
+ // file_path was NOT NULL — recreate table to make it nullable
+ const hasProvider = db.prepare("SELECT 1 FROM pragma_table_info('journey_photos') WHERE name = 'provider'").get();
+ if (hasProvider) {
+ // Already has the column, just ensure file_path is nullable by recreating
+ try {
+ db.exec(`
+ CREATE TABLE journey_photos_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ entry_id INTEGER NOT NULL,
+ provider TEXT NOT NULL DEFAULT 'local',
+ asset_id TEXT,
+ owner_id INTEGER REFERENCES users(id),
+ file_path TEXT,
+ thumbnail_path TEXT,
+ caption TEXT,
+ sort_order INTEGER DEFAULT 0,
+ width INTEGER,
+ height INTEGER,
+ shared INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
+ );
+ INSERT INTO journey_photos_new SELECT id, entry_id, provider, asset_id, owner_id, file_path, thumbnail_path, caption, sort_order, width, height, shared, created_at FROM journey_photos;
+ DROP TABLE journey_photos;
+ ALTER TABLE journey_photos_new RENAME TO journey_photos;
+ CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id);
+ `);
+ } catch {}
+ }
+ },
+ // Migration 89: Journey cover image
+ () => {
+ try { db.exec('ALTER TABLE journeys ADD COLUMN cover_image TEXT'); } catch {}
+ },
+ // Migration 90: Pros/Cons for journey entries
+ () => {
+ try { db.exec('ALTER TABLE journey_entries ADD COLUMN pros_cons TEXT'); } catch {}
+ },
+ // Migration 91: Journey share tokens
+ () => {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS journey_share_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ journey_id INTEGER NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ created_by INTEGER NOT NULL,
+ share_timeline INTEGER DEFAULT 1,
+ share_gallery INTEGER DEFAULT 1,
+ share_map INTEGER DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
+ FOREIGN KEY (created_by) REFERENCES users(id)
+ )
+ `);
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_journey_share_journey ON journey_share_tokens(journey_id)');
+ },
// Migration: OAuth 2.1 clients, consents, and tokens for MCP
() => {
db.exec(`
diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts
index 6ffed848..2f6ee3ea 100644
--- a/server/src/db/seeds.ts
+++ b/server/src/db/seeds.ts
@@ -89,6 +89,7 @@ function seedAddons(db: Database.Database): void {
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
{ id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
+ { id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
];
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
diff --git a/server/src/routes/journey.ts b/server/src/routes/journey.ts
new file mode 100644
index 00000000..7db7a9a9
--- /dev/null
+++ b/server/src/routes/journey.ts
@@ -0,0 +1,290 @@
+import express, { Request, Response } from 'express';
+import multer from 'multer';
+import path from 'node:path';
+import fs from 'node:fs';
+import crypto from 'node:crypto';
+import { authenticate } from '../middleware/auth';
+import { AuthRequest } from '../types';
+import * as svc from '../services/journeyService';
+import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
+import { uploadToImmich } from '../services/memories/immichService';
+
+const router = express.Router();
+
+const uploadsBase = path.join(__dirname, '../../uploads/journey');
+
+const storage = multer.diskStorage({
+ destination: (_req, _file, cb) => {
+ if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true });
+ cb(null, uploadsBase);
+ },
+ filename: (_req, file, cb) => {
+ const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
+ cb(null, `${crypto.randomUUID()}${ext}`);
+ },
+});
+
+const upload = multer({
+ storage,
+ limits: { fileSize: 20 * 1024 * 1024 },
+});
+
+// ── Static prefix routes (MUST come before /:id) ─────────────────────────
+
+router.get('/', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ res.json({ journeys: svc.listJourneys(authReq.user.id) });
+});
+
+router.post('/', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { title, subtitle, trip_ids } = req.body || {};
+ if (!title || typeof title !== 'string' || !title.trim()) {
+ return res.status(400).json({ error: 'Title is required' });
+ }
+ const journey = svc.createJourney(authReq.user.id, {
+ title: title.trim(),
+ subtitle,
+ trip_ids: Array.isArray(trip_ids) ? trip_ids : [],
+ });
+ res.status(201).json(journey);
+});
+
+router.get('/suggestions', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ res.json({ trips: svc.getSuggestions(authReq.user.id) });
+});
+
+router.get('/available-trips', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ res.json({ trips: svc.listUserTrips(authReq.user.id) });
+});
+
+// ── Entries (prefix /entries — before /:id) ──────────────────────────────
+
+router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {});
+ if (!result) return res.status(404).json({ error: 'Entry not found' });
+ res.json(result);
+});
+
+router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id)) {
+ return res.status(404).json({ error: 'Entry not found' });
+ }
+ res.json({ success: true });
+});
+
+// ── Photos (prefix /photos and /entries — before /:id) ───────────────────
+
+router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10), async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const files = req.files as Express.Multer.File[];
+ if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
+
+ const results: any[] = [];
+ for (const file of files) {
+ const relativePath = `journey/${file.filename}`;
+ const photo = svc.addPhoto(
+ Number(req.params.entryId),
+ authReq.user.id,
+ relativePath,
+ undefined,
+ req.body?.caption
+ );
+ if (photo) {
+ // sync to Immich if connected — update the same photo record
+ try {
+ const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
+ if (immichId) {
+ svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
+ photo.provider = 'immich' as any;
+ photo.asset_id = immichId;
+ photo.owner_id = authReq.user.id;
+ }
+ } catch {}
+ results.push(photo);
+ }
+ }
+
+ if (!results.length) return res.status(403).json({ error: 'Not allowed' });
+ res.status(201).json({ photos: results });
+});
+
+router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { provider, asset_id, caption } = req.body || {};
+ if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
+ const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
+ if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
+ res.status(201).json(photo);
+});
+
+// Link an existing photo to a (different) entry
+router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { photo_id } = req.body || {};
+ if (!photo_id) return res.status(400).json({ error: 'photo_id required' });
+ const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(photo_id), authReq.user.id);
+ if (!result) return res.status(403).json({ error: 'Not allowed' });
+ res.status(201).json(result);
+});
+
+router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {});
+ if (!result) return res.status(404).json({ error: 'Photo not found' });
+ res.json(result);
+});
+
+router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id);
+ if (!photo) return res.status(404).json({ error: 'Photo not found' });
+ // delete local file
+ if (photo.file_path) {
+ const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
+ try { fs.unlinkSync(fullPath); } catch {}
+ }
+ // only delete from Immich if the photo was UPLOADED through TREK (has local file)
+ // photos imported from Immich (no file_path) are just references — don't touch Immich
+ if (photo.provider === 'immich' && photo.asset_id && photo.file_path) {
+ try {
+ const { getImmichCredentials } = await import('../services/memories/immichService');
+ const creds = getImmichCredentials(authReq.user.id);
+ if (creds) {
+ const { safeFetch } = await import('../utils/ssrfGuard');
+ await safeFetch(`${creds.immich_url}/api/assets`, {
+ method: 'DELETE',
+ headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ids: [photo.asset_id] }),
+ });
+ }
+ } catch {}
+ }
+ res.json({ success: true });
+});
+
+// ── Journeys /:id (parameterized routes AFTER static prefixes) ───────────
+
+router.get('/:id', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const data = svc.getJourneyFull(Number(req.params.id), authReq.user.id);
+ if (!data) return res.status(404).json({ error: 'Journey not found' });
+ res.json(data);
+});
+
+router.patch('/:id', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const result = svc.updateJourney(Number(req.params.id), authReq.user.id, req.body || {});
+ if (!result) return res.status(404).json({ error: 'Journey not found' });
+ res.json(result);
+});
+
+router.post('/:id/cover', authenticate, upload.single('cover'), (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
+ const relativePath = `journey/${req.file.filename}`;
+ const result = svc.updateJourney(Number(req.params.id), authReq.user.id, { cover_image: relativePath });
+ if (!result) return res.status(404).json({ error: 'Journey not found' });
+ res.json(result);
+});
+
+router.delete('/:id', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!svc.deleteJourney(Number(req.params.id), authReq.user.id)) {
+ return res.status(404).json({ error: 'Journey not found' });
+ }
+ res.json({ success: true });
+});
+
+// ── Journey trips ────────────────────────────────────────────────────────
+
+router.post('/:id/trips', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { trip_id } = req.body || {};
+ if (!trip_id) return res.status(400).json({ error: 'trip_id required' });
+ if (!svc.addTripToJourney(Number(req.params.id), trip_id, authReq.user.id)) {
+ return res.status(403).json({ error: 'Not allowed' });
+ }
+ res.json({ success: true });
+});
+
+router.delete('/:id/trips/:tripId', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!svc.removeTripFromJourney(Number(req.params.id), Number(req.params.tripId), authReq.user.id)) {
+ return res.status(403).json({ error: 'Not allowed' });
+ }
+ res.json({ success: true });
+});
+
+// ── Entries under journey ────────────────────────────────────────────────
+
+router.get('/:id/entries', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const entries = svc.listEntries(Number(req.params.id), authReq.user.id);
+ if (!entries) return res.status(404).json({ error: 'Journey not found' });
+ res.json({ entries });
+});
+
+router.post('/:id/entries', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { entry_date } = req.body || {};
+ if (!entry_date) return res.status(400).json({ error: 'entry_date is required' });
+ const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body);
+ if (!entry) return res.status(404).json({ error: 'Journey not found' });
+ res.status(201).json(entry);
+});
+
+// ── Contributors ─────────────────────────────────────────────────────────
+
+router.post('/:id/contributors', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { user_id, role } = req.body || {};
+ if (!user_id) return res.status(400).json({ error: 'user_id required' });
+ if (!svc.addContributor(Number(req.params.id), authReq.user.id, user_id, role || 'viewer')) {
+ return res.status(403).json({ error: 'Not allowed' });
+ }
+ res.status(201).json({ success: true });
+});
+
+router.patch('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { role } = req.body || {};
+ if (!svc.updateContributorRole(Number(req.params.id), authReq.user.id, Number(req.params.userId), role)) {
+ return res.status(403).json({ error: 'Not allowed' });
+ }
+ res.json({ success: true });
+});
+
+router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!svc.removeContributor(Number(req.params.id), authReq.user.id, Number(req.params.userId))) {
+ return res.status(403).json({ error: 'Not allowed' });
+ }
+ res.json({ success: true });
+});
+
+// ── Share Link ────────────────────────────────────────────────────────────
+
+router.get('/:id/share-link', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const link = getJourneyShareLink(Number(req.params.id));
+ res.json({ link });
+});
+
+router.post('/:id/share-link', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { share_timeline, share_gallery, share_map } = req.body || {};
+ const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, { share_timeline, share_gallery, share_map });
+ res.json(result);
+});
+
+router.delete('/:id/share-link', authenticate, (req: Request, res: Response) => {
+ deleteJourneyShareLink(Number(req.params.id));
+ res.json({ success: true });
+});
+
+export default router;
diff --git a/server/src/routes/journeyPublic.ts b/server/src/routes/journeyPublic.ts
new file mode 100644
index 00000000..0bd1fac0
--- /dev/null
+++ b/server/src/routes/journeyPublic.ts
@@ -0,0 +1,50 @@
+import express, { Request, Response } from 'express';
+import { getPublicJourney, validateShareTokenForAsset } from '../services/journeyShareService';
+import { streamImmichAsset } from '../services/memories/immichService';
+import path from 'node:path';
+import fs from 'node:fs';
+
+const router = express.Router();
+
+router.get('/:token', (req: Request, res: Response) => {
+ const data = getPublicJourney(req.params.token);
+ if (!data) return res.status(404).json({ error: 'Not found' });
+ res.json(data);
+});
+
+// Public photo proxy — validates share token instead of auth
+router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => {
+ const { token, provider, assetId, ownerId, kind } = req.params;
+
+ // Validate token and that this asset belongs to the shared journey
+ const valid = validateShareTokenForAsset(token, assetId);
+ if (!valid) return res.status(404).json({ error: 'Not found' });
+
+ if (provider === 'local') {
+ // Local file — assetId is the file_path
+ const filePath = path.join(__dirname, '../../uploads/journey', assetId);
+ const resolved = path.resolve(filePath);
+ const uploadsDir = path.resolve(__dirname, '../../uploads');
+ if (!resolved.startsWith(uploadsDir) || !fs.existsSync(resolved)) {
+ return res.status(404).json({ error: 'Not found' });
+ }
+ res.set('Cache-Control', 'public, max-age=86400');
+ return res.sendFile(resolved);
+ }
+
+ // Immich/Synology — proxy through
+ const effectiveOwnerId = valid.ownerId || Number(ownerId);
+ if (provider === 'immich') {
+ await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId);
+ } else {
+ // Synology or other providers — try dynamic import
+ try {
+ const { streamSynologyAsset } = await import('../services/memories/synologyService');
+ await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original');
+ } catch {
+ res.status(404).json({ error: 'Provider not supported' });
+ }
+ }
+});
+
+export default router;
diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts
index 642c95a4..62c3cafd 100644
--- a/server/src/routes/places.ts
+++ b/server/src/routes/places.ts
@@ -16,6 +16,7 @@ import {
importGoogleList,
searchPlaceImage,
} from '../services/placeService';
+import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
@@ -49,6 +50,7 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
const place = createPlace(tripId, req.body);
res.status(201).json({ place });
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
+ try { onPlaceCreated(Number(tripId), place.id); } catch {}
});
// Import places from GPX file with full track geometry (must be before /:id)
@@ -142,6 +144,7 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
res.json({ place });
broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id'] as string);
+ try { onPlaceUpdated(place.id); } catch {}
});
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
@@ -151,6 +154,7 @@ router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Respo
const { tripId, id } = req.params;
+ try { onPlaceDeleted(Number(id)); } catch {} // sync before actual delete
const deleted = deletePlace(tripId, id);
if (!deleted) {
return res.status(404).json({ error: 'Place not found' });
diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts
new file mode 100644
index 00000000..18121ad9
--- /dev/null
+++ b/server/src/services/journeyService.ts
@@ -0,0 +1,727 @@
+import { db } from '../db/database';
+import { broadcastToUser } from '../websocket';
+import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
+
+function ts(): number {
+ return Date.now();
+}
+
+function broadcastJourneyEvent(journeyId: number, event: string, data: Record, excludeUserId?: number) {
+ const contributors = db.prepare(
+ 'SELECT user_id FROM journey_contributors WHERE journey_id = ?'
+ ).all(journeyId) as { user_id: number }[];
+ const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number } | undefined;
+
+ const userIds = new Set(contributors.map(c => c.user_id));
+ if (owner) userIds.add(owner.user_id);
+
+ for (const uid of userIds) {
+ if (uid === excludeUserId) continue;
+ broadcastToUser(uid, { type: event, journeyId, ...data });
+ }
+}
+
+// ── Access control ───────────────────────────────────────────────────────
+
+export function canAccessJourney(journeyId: number, userId: number): Journey | null {
+ const own = db.prepare('SELECT * FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId) as Journey | undefined;
+ if (own) return own;
+ const contrib = db.prepare(
+ 'SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
+ ).get(journeyId, userId);
+ if (contrib) return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey || null;
+ return null;
+}
+
+export function isOwner(journeyId: number, userId: number): boolean {
+ return !!db.prepare('SELECT 1 FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId);
+}
+
+export function canEdit(journeyId: number, userId: number): boolean {
+ if (isOwner(journeyId, userId)) return true;
+ const c = db.prepare(
+ "SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?"
+ ).get(journeyId, userId) as { role: string } | undefined;
+ return c?.role === 'editor' || c?.role === 'owner';
+}
+
+// ── Journey CRUD ─────────────────────────────────────────────────────────
+
+export function listJourneys(userId: number) {
+ return db.prepare(`
+ SELECT DISTINCT j.*,
+ (SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
+ (SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
+ (SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as city_count
+ FROM journeys j
+ LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ?
+ WHERE j.user_id = ? OR jc.user_id = ?
+ ORDER BY j.updated_at DESC
+ `).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; city_count: number })[];
+}
+
+export function createJourney(userId: number, data: {
+ title: string;
+ subtitle?: string;
+ trip_ids?: number[];
+}): Journey {
+ const now = ts();
+ const res = db.prepare(`
+ INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at)
+ VALUES (?, ?, ?, 'active', ?, ?)
+ `).run(userId, data.title, data.subtitle || null, now, now);
+
+ const journeyId = Number(res.lastInsertRowid);
+
+ // add owner as contributor
+ db.prepare(
+ 'INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)'
+ ).run(journeyId, userId, 'owner', now);
+
+ // link trips and sync skeleton entries
+ if (data.trip_ids?.length) {
+ for (const tripId of data.trip_ids) {
+ addTripToJourney(journeyId, tripId, userId);
+ }
+ }
+
+ return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
+}
+
+export function getJourneyFull(journeyId: number, userId: number) {
+ const journey = canAccessJourney(journeyId, userId);
+ if (!journey) return null;
+
+ const entries = db.prepare(
+ 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
+ ).all(journeyId) as JourneyEntry[];
+
+ const photos = db.prepare(
+ 'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
+ ).all(journeyId) as JourneyPhoto[];
+
+ // group photos by entry
+ const photosByEntry: Record = {};
+ for (const p of photos) {
+ (photosByEntry[p.entry_id] ||= []).push(p);
+ }
+
+ const enrichedEntries = entries.map(e => ({
+ ...e,
+ tags: e.tags ? JSON.parse(e.tags) : [],
+ pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
+ photos: photosByEntry[e.id] || [],
+ source_trip_name: e.source_trip_id
+ ? (db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined)?.title || null
+ : null,
+ }));
+
+ // linked trips
+ const trips = db.prepare(`
+ SELECT jt.trip_id, jt.added_at, t.title, t.start_date, t.end_date, t.cover_image, t.currency,
+ (SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count
+ FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id
+ WHERE jt.journey_id = ? ORDER BY t.start_date ASC
+ `).all(journeyId);
+
+ // contributors
+ const contributors = db.prepare(`
+ SELECT jc.journey_id, jc.user_id, jc.role, jc.added_at, u.username, u.avatar
+ FROM journey_contributors jc JOIN users u ON jc.user_id = u.id
+ WHERE jc.journey_id = ? ORDER BY jc.added_at
+ `).all(journeyId);
+
+ // stats
+ const entryCount = entries.filter(e => e.type === 'entry').length;
+ const photoCount = photos.length;
+ const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
+
+ return {
+ ...journey,
+ entries: enrichedEntries,
+ trips,
+ contributors,
+ stats: { entries: entryCount, photos: photoCount, cities: cities.length },
+ };
+}
+
+export function updateJourney(journeyId: number, userId: number, data: Partial<{
+ title: string;
+ subtitle: string;
+ cover_gradient: string;
+ cover_image: string;
+ status: string;
+}>): Journey | null {
+ if (!canEdit(journeyId, userId)) return null;
+
+ const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
+ const fields: string[] = [];
+ const values: unknown[] = [];
+ for (const [key, val] of Object.entries(data)) {
+ if (val !== undefined && allowed.includes(key)) {
+ fields.push(`${key} = ?`);
+ values.push(val);
+ }
+ }
+ if (fields.length === 0) return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
+
+ fields.push('updated_at = ?');
+ values.push(ts());
+ values.push(journeyId);
+ db.prepare(`UPDATE journeys SET ${fields.join(', ')} WHERE id = ?`).run(...values);
+ return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
+}
+
+export function deleteJourney(journeyId: number, userId: number): boolean {
+ if (!isOwner(journeyId, userId)) return false;
+ db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId);
+ return true;
+}
+
+// ── Trip management ──────────────────────────────────────────────────────
+
+export function addTripToJourney(journeyId: number, tripId: number, userId: number): boolean {
+ const now = ts();
+ try {
+ db.prepare(
+ 'INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)'
+ ).run(journeyId, tripId, now);
+ } catch { return false; }
+
+ // sync skeleton entries for all places in this trip
+ syncTripPlaces(journeyId, tripId, userId);
+ // import existing trip photos (Immich/Synology) with sharing settings
+ syncTripPhotos(journeyId, tripId);
+ broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }, userId);
+ return true;
+}
+
+export function removeTripFromJourney(journeyId: number, tripId: number, userId: number): boolean {
+ if (!isOwner(journeyId, userId)) return false;
+
+ // remove skeleton entries that haven't been filled in
+ db.prepare(`
+ DELETE FROM journey_entries
+ WHERE journey_id = ? AND source_trip_id = ? AND type = 'skeleton'
+ `).run(journeyId, tripId);
+
+ // detach filled entries from this trip
+ db.prepare(`
+ UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL
+ WHERE journey_id = ? AND source_trip_id = ? AND type != 'skeleton'
+ `).run(journeyId, tripId);
+
+ db.prepare('DELETE FROM journey_trips WHERE journey_id = ? AND trip_id = ?').run(journeyId, tripId);
+ return true;
+}
+
+// ── Sync engine ──────────────────────────────────────────────────────────
+
+export function syncTripPlaces(journeyId: number, tripId: number, authorId: number) {
+ const places = db.prepare(`
+ SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, da.assignment_end_time, d.day_number
+ FROM places p
+ LEFT JOIN day_assignments da ON da.place_id = p.id
+ LEFT JOIN days d ON da.day_id = d.id
+ WHERE p.trip_id = ?
+ ORDER BY d.day_number ASC, da.order_index ASC
+ `).all(tripId) as any[];
+
+ const now = ts();
+ const existing = db.prepare(
+ 'SELECT source_place_id FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?'
+ ).all(journeyId, tripId) as { source_place_id: number }[];
+ const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
+
+ for (const place of places) {
+ if (existingPlaceIds.has(place.id)) continue;
+
+ const entryDate = place.day_date || new Date().toISOString().split('T')[0];
+ const entryTime = place.assignment_time || place.place_time || null;
+
+ db.prepare(`
+ INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ journeyId, tripId, place.id, authorId,
+ place.name, entryDate, entryTime,
+ place.address || place.name, place.lat || null, place.lng || null,
+ place.day_number || 0, now, now
+ );
+ }
+}
+
+// import trip_photos into journey when a trip is linked
+function syncTripPhotos(journeyId: number, tripId: number) {
+ const tripPhotos = db.prepare(
+ 'SELECT * FROM trip_photos WHERE trip_id = ?'
+ ).all(tripId) as { id: number; trip_id: number; user_id: number; asset_id: string; provider: string; shared: number }[];
+ if (!tripPhotos.length) return;
+
+ const now = ts();
+
+ // find or create a "Photos" entry for this trip's photos
+ let photoEntry = db.prepare(`
+ SELECT id FROM journey_entries
+ WHERE journey_id = ? AND source_trip_id = ? AND title = '[Trip Photos]' AND type = 'entry'
+ `).get(journeyId, tripId) as { id: number } | undefined;
+
+ if (!photoEntry) {
+ // get trip date for the entry
+ const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined;
+ const entryDate = trip?.start_date || new Date().toISOString().split('T')[0];
+ const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number };
+
+ const res = db.prepare(`
+ INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
+ VALUES (?, ?, ?, 'entry', '[Trip Photos]', ?, 999, ?, ?)
+ `).run(journeyId, tripId, owner.user_id, entryDate, now, now);
+ photoEntry = { id: Number(res.lastInsertRowid) };
+ }
+
+ // import each trip photo, skip duplicates
+ for (const tp of tripPhotos) {
+ const exists = db.prepare(
+ 'SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?'
+ ).get(photoEntry.id, tp.provider, tp.asset_id);
+ if (exists) continue;
+
+ const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null };
+
+ db.prepare(`
+ INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, shared, sort_order, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `).run(photoEntry.id, tp.provider, tp.asset_id, tp.user_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
+ }
+}
+
+// called when a trip place is created
+export function onPlaceCreated(tripId: number, placeId: number) {
+ const links = db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as { journey_id: number }[];
+ if (!links.length) return;
+
+ const place = db.prepare(`
+ SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number
+ FROM places p
+ LEFT JOIN day_assignments da ON da.place_id = p.id
+ LEFT JOIN days d ON da.day_id = d.id
+ WHERE p.id = ?
+ `).get(placeId) as any;
+ if (!place) return;
+
+ const now = ts();
+ for (const link of links) {
+ const already = db.prepare(
+ 'SELECT 1 FROM journey_entries WHERE journey_id = ? AND source_place_id = ?'
+ ).get(link.journey_id, placeId);
+ if (already) continue;
+
+ const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
+ const entryDate = place.day_date || new Date().toISOString().split('T')[0];
+
+ db.prepare(`
+ INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?)
+ `).run(
+ link.journey_id, tripId, placeId, journey.user_id,
+ place.name, entryDate, place.assignment_time || place.place_time || null,
+ place.address || place.name, place.lat || null, place.lng || null,
+ now, now
+ );
+ }
+}
+
+// called when a trip place is updated
+export function onPlaceUpdated(placeId: number) {
+ const entries = db.prepare(
+ 'SELECT * FROM journey_entries WHERE source_place_id = ?'
+ ).all(placeId) as JourneyEntry[];
+ if (!entries.length) return;
+
+ const place = db.prepare(`
+ SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number
+ FROM places p
+ LEFT JOIN day_assignments da ON da.place_id = p.id
+ LEFT JOIN days d ON da.day_id = d.id
+ WHERE p.id = ?
+ `).get(placeId) as any;
+ if (!place) return;
+
+ const now = ts();
+ for (const entry of entries) {
+ if (entry.type === 'skeleton') {
+ // update everything on skeletons
+ db.prepare(`
+ UPDATE journey_entries SET title = ?, entry_date = ?, entry_time = ?, location_name = ?, location_lat = ?, location_lng = ?, updated_at = ?
+ WHERE id = ?
+ `).run(
+ place.name,
+ place.day_date || entry.entry_date,
+ place.assignment_time || place.place_time || entry.entry_time,
+ place.address || place.name,
+ place.lat || null, place.lng || null,
+ now, entry.id
+ );
+ } else {
+ // for filled entries, only update location silently
+ db.prepare(`
+ UPDATE journey_entries SET location_name = ?, location_lat = ?, location_lng = ?, updated_at = ?
+ WHERE id = ?
+ `).run(place.address || place.name, place.lat || null, place.lng || null, now, entry.id);
+ }
+ }
+}
+
+// called when a trip place is deleted
+export function onPlaceDeleted(placeId: number) {
+ const entries = db.prepare(
+ 'SELECT * FROM journey_entries WHERE source_place_id = ?'
+ ).all(placeId) as JourneyEntry[];
+
+ for (const entry of entries) {
+ if (entry.type === 'skeleton') {
+ // no content: just delete
+ const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entry.id);
+ if (!hasPhotos && !entry.story) {
+ db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id);
+ continue;
+ }
+ }
+ // entry has content: keep it, detach, add note
+ const note = '\n\n> _Note: the original trip place was removed from the trip plan_';
+ const newStory = (entry.story || '') + note;
+ db.prepare(
+ 'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?'
+ ).run(entry.type === 'skeleton' ? 'entry' : entry.type, newStory, ts(), entry.id);
+ }
+}
+
+// ── Entries ──────────────────────────────────────────────────────────────
+
+export function listEntries(journeyId: number, userId: number) {
+ if (!canAccessJourney(journeyId, userId)) return null;
+
+ const entries = db.prepare(
+ 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
+ ).all(journeyId) as JourneyEntry[];
+
+ const photos = db.prepare(
+ 'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
+ ).all(journeyId) as JourneyPhoto[];
+
+ const photosByEntry: Record = {};
+ for (const p of photos) {
+ (photosByEntry[p.entry_id] ||= []).push(p);
+ }
+
+ return entries.map(e => ({
+ ...e,
+ tags: e.tags ? JSON.parse(e.tags) : [],
+ pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
+ photos: photosByEntry[e.id] || [],
+ source_trip_name: e.source_trip_id
+ ? (db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined)?.title || null
+ : null,
+ }));
+}
+
+export function createEntry(journeyId: number, userId: number, data: {
+ type?: string;
+ title?: string;
+ story?: string;
+ entry_date: string;
+ entry_time?: string;
+ location_name?: string;
+ location_lat?: number;
+ location_lng?: number;
+ mood?: string;
+ weather?: string;
+ tags?: string[];
+ pros_cons?: { pros: string[]; cons: string[] };
+ visibility?: string;
+}): JourneyEntry | null {
+ if (!canEdit(journeyId, userId)) return null;
+
+ const now = ts();
+ const maxOrder = db.prepare(
+ 'SELECT MAX(sort_order) as m FROM journey_entries WHERE journey_id = ? AND entry_date = ?'
+ ).get(journeyId, data.entry_date) as { m: number | null };
+
+ const prosConsJson = data.pros_cons && (data.pros_cons.pros.length || data.pros_cons.cons.length)
+ ? JSON.stringify(data.pros_cons) : null;
+
+ const res = db.prepare(`
+ INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, tags, pros_cons, visibility, sort_order, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ journeyId, userId,
+ data.type || 'entry',
+ data.title || null,
+ data.story || null,
+ data.entry_date,
+ data.entry_time || null,
+ data.location_name || null,
+ data.location_lat ?? null,
+ data.location_lng ?? null,
+ data.mood || null,
+ data.weather || null,
+ data.tags?.length ? JSON.stringify(data.tags) : null,
+ prosConsJson,
+ data.visibility || 'private',
+ (maxOrder?.m ?? -1) + 1,
+ now, now
+ );
+
+ const created = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyEntry;
+ broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, userId);
+ return created;
+}
+
+export function updateEntry(entryId: number, userId: number, data: Partial<{
+ type: string;
+ title: string;
+ story: string;
+ entry_date: string;
+ entry_time: string;
+ location_name: string;
+ location_lat: number;
+ location_lng: number;
+ mood: string;
+ weather: string;
+ tags: string[];
+ pros_cons: { pros: string[]; cons: string[] };
+ visibility: string;
+ sort_order: number;
+}>): JourneyEntry | null {
+ const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
+ if (!entry) return null;
+ if (!canEdit(entry.journey_id, userId)) return null;
+
+ const fields: string[] = [];
+ const values: unknown[] = [];
+
+ for (const [key, val] of Object.entries(data)) {
+ if (val === undefined) continue;
+ if (key === 'tags') {
+ fields.push('tags = ?');
+ values.push(Array.isArray(val) ? JSON.stringify(val) : val);
+ } else if (key === 'pros_cons') {
+ fields.push('pros_cons = ?');
+ values.push(val && typeof val === 'object' ? JSON.stringify(val) : val);
+ } else {
+ fields.push(`${key} = ?`);
+ values.push(val);
+ }
+ }
+
+ // if adding story to a skeleton, promote to entry
+ if (entry.type === 'skeleton' && data.story && data.story.trim()) {
+ fields.push('type = ?');
+ values.push('entry');
+ }
+
+ if (fields.length === 0) return entry;
+
+ fields.push('updated_at = ?');
+ values.push(ts());
+ values.push(entryId);
+ db.prepare(`UPDATE journey_entries SET ${fields.join(', ')} WHERE id = ?`).run(...values);
+
+ // touch the journey
+ db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(ts(), entry.journey_id);
+
+ const updated = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry;
+ broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, userId);
+ return updated;
+}
+
+export function deleteEntry(entryId: number, userId: number): boolean {
+ const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
+ if (!entry) return false;
+ if (!canEdit(entry.journey_id, userId)) return false;
+
+ // move photos to hidden Gallery entry so they stay in the gallery
+ const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entryId);
+ if (hasPhotos) {
+ let gallery = db.prepare(
+ "SELECT id FROM journey_entries WHERE journey_id = ? AND title = 'Gallery' AND id != ?"
+ ).get(entry.journey_id, entryId) as { id: number } | undefined;
+ if (!gallery) {
+ const now = ts();
+ const res = db.prepare(`
+ INSERT INTO journey_entries (journey_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
+ VALUES (?, ?, 'entry', 'Gallery', ?, 999, ?, ?)
+ `).run(entry.journey_id, entry.author_id, entry.entry_date, now, now);
+ gallery = { id: Number(res.lastInsertRowid) };
+ }
+ db.prepare('UPDATE journey_photos SET entry_id = ? WHERE entry_id = ?').run(gallery.id, entryId);
+ }
+
+ db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
+ broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, userId);
+ return true;
+}
+
+// ── Photos ───────────────────────────────────────────────────────────────
+
+export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
+ const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
+ if (!entry) return null;
+ if (!canEdit(entry.journey_id, userId)) return null;
+
+ const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
+ const now = ts();
+
+ const res = db.prepare(`
+ INSERT INTO journey_photos (entry_id, provider, file_path, thumbnail_path, caption, sort_order, created_at)
+ VALUES (?, 'local', ?, ?, ?, ?, ?)
+ `).run(entryId, filePath, thumbnailPath || null, caption || null, (maxOrder?.m ?? -1) + 1, now);
+
+ return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto;
+}
+
+export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
+ const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
+ if (!entry) return null;
+ if (!canEdit(entry.journey_id, userId)) return null;
+
+ // skip if already added
+ const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?').get(entryId, provider, assetId);
+ if (exists) return null;
+
+ const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
+ const now = ts();
+
+ const res = db.prepare(`
+ INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, caption, sort_order, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `).run(entryId, provider, assetId, userId, caption || null, (maxOrder?.m ?? -1) + 1, now);
+
+ return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto;
+}
+
+export function linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null {
+ const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
+ if (!entry) return null;
+ if (!canEdit(entry.journey_id, userId)) return null;
+
+ const source = db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto | undefined;
+ if (!source) return null;
+
+ if (source.entry_id === entryId) return source;
+
+ const oldEntryId = source.entry_id;
+
+ // move photo to the target entry
+ db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
+
+ // clean up: if old entry was a "Gallery" entry and is now empty, delete it
+ const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(oldEntryId) as JourneyEntry | undefined;
+ if (oldEntry && oldEntry.title === 'Gallery') {
+ const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(oldEntryId) as { c: number };
+ if (remaining.c === 0) {
+ db.prepare('DELETE FROM journey_entries WHERE id = ?').run(oldEntryId);
+ }
+ }
+
+ return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
+}
+
+export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
+ db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(provider, assetId, ownerId, photoId);
+}
+
+export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
+ const photo = db.prepare(`
+ SELECT jp.*, je.journey_id FROM journey_photos jp
+ JOIN journey_entries je ON jp.entry_id = je.id
+ WHERE jp.id = ?
+ `).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
+ if (!photo) return null;
+ if (!canEdit(photo.journey_id, userId)) return null;
+
+ const fields: string[] = [];
+ const values: unknown[] = [];
+ if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); }
+ if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
+ if (!fields.length) return photo;
+
+ values.push(photoId);
+ db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
+ return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
+}
+
+export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null {
+ const photo = db.prepare(`
+ SELECT jp.*, je.journey_id FROM journey_photos jp
+ JOIN journey_entries je ON jp.entry_id = je.id
+ WHERE jp.id = ?
+ `).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
+ if (!photo) return null;
+ if (!canEdit(photo.journey_id, userId)) return null;
+
+ db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
+ return photo;
+}
+
+// ── Contributors ─────────────────────────────────────────────────────────
+
+export function addContributor(journeyId: number, userId: number, targetUserId: number, role: 'editor' | 'viewer'): boolean {
+ if (!isOwner(journeyId, userId)) return false;
+ if (targetUserId === userId) return false;
+ try {
+ db.prepare(
+ 'INSERT OR REPLACE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)'
+ ).run(journeyId, targetUserId, role, ts());
+ broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
+ return true;
+ } catch { return false; }
+}
+
+export function updateContributorRole(journeyId: number, userId: number, targetUserId: number, role: 'editor' | 'viewer'): boolean {
+ if (!isOwner(journeyId, userId)) return false;
+ db.prepare(
+ 'UPDATE journey_contributors SET role = ? WHERE journey_id = ? AND user_id = ?'
+ ).run(role, journeyId, targetUserId);
+ broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
+ return true;
+}
+
+export function removeContributor(journeyId: number, userId: number, targetUserId: number): boolean {
+ if (!isOwner(journeyId, userId)) return false;
+ db.prepare(
+ "DELETE FROM journey_contributors WHERE journey_id = ? AND user_id = ? AND role != 'owner'"
+ ).run(journeyId, targetUserId);
+ return true;
+}
+
+// ── Suggestions ──────────────────────────────────────────────────────────
+
+export function getSuggestions(userId: number) {
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
+ return db.prepare(`
+ SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image,
+ (SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count
+ FROM trips t
+ LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ?
+ WHERE (t.user_id = ? OR tm.user_id = ?)
+ AND t.end_date IS NOT NULL
+ AND t.end_date >= ?
+ AND t.end_date <= date('now')
+ AND t.id NOT IN (SELECT trip_id FROM journey_trips)
+ ORDER BY t.end_date DESC
+ `).all(userId, userId, userId, thirtyDaysAgo);
+}
+
+// ── User trips (for trip picker) ─────────────────────────────────────────
+
+export function listUserTrips(userId: number) {
+ return db.prepare(`
+ SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image,
+ (SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count
+ FROM trips t
+ LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ?
+ WHERE t.user_id = ? OR tm.user_id = ?
+ ORDER BY t.start_date DESC
+ `).all(userId, userId, userId);
+}
diff --git a/server/src/services/journeyShareService.ts b/server/src/services/journeyShareService.ts
new file mode 100644
index 00000000..7d02ee78
--- /dev/null
+++ b/server/src/services/journeyShareService.ts
@@ -0,0 +1,143 @@
+import { db } from '../db/database';
+import crypto from 'crypto';
+
+interface JourneySharePermissions {
+ share_timeline?: boolean;
+ share_gallery?: boolean;
+ share_map?: boolean;
+}
+
+interface JourneyShareTokenInfo {
+ token: string;
+ created_at: string;
+ share_timeline: boolean;
+ share_gallery: boolean;
+ share_map: boolean;
+}
+
+export function createOrUpdateJourneyShareLink(
+ journeyId: number,
+ createdBy: number,
+ permissions: JourneySharePermissions
+): { token: string; created: boolean } {
+ const {
+ share_timeline = true,
+ share_gallery = true,
+ share_map = true,
+ } = permissions;
+
+ const existing = db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined;
+ if (existing) {
+ db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ? WHERE journey_id = ?')
+ .run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, journeyId);
+ return { token: existing.token, created: false };
+ }
+
+ const token = crypto.randomBytes(24).toString('base64url');
+ db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map) VALUES (?, ?, ?, ?, ?, ?)')
+ .run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0);
+ return { token, created: true };
+}
+
+export function getJourneyShareLink(journeyId: number): JourneyShareTokenInfo | null {
+ const row = db.prepare('SELECT * FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as any;
+ if (!row) return null;
+ return {
+ token: row.token,
+ created_at: row.created_at,
+ share_timeline: !!row.share_timeline,
+ share_gallery: !!row.share_gallery,
+ share_map: !!row.share_map,
+ };
+}
+
+export function deleteJourneyShareLink(journeyId: number): void {
+ db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId);
+}
+
+export function validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
+ const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
+ if (!row) return null;
+ const photo = db.prepare(`
+ SELECT jp.*, je.journey_id FROM journey_photos jp
+ JOIN journey_entries je ON jp.entry_id = je.id
+ WHERE jp.id = ? AND je.journey_id = ?
+ `).get(photoId, row.journey_id) as any;
+ if (!photo) return null;
+ const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
+ return journey ? { journeyId: row.journey_id, ownerId: photo.owner_id || journey.user_id } : null;
+}
+
+export function validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null {
+ const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
+ if (!row) return null;
+ // Check if this asset belongs to any photo in the shared journey
+ const photo = db.prepare(`
+ SELECT jp.owner_id FROM journey_photos jp
+ JOIN journey_entries je ON jp.entry_id = je.id
+ WHERE jp.asset_id = ? AND je.journey_id = ?
+ `).get(assetId, row.journey_id) as any;
+ if (!photo) {
+ // Fallback: get journey owner
+ const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
+ return journey ? { ownerId: journey.user_id } : null;
+ }
+ return { ownerId: photo.owner_id };
+}
+
+export function getPublicJourney(token: string) {
+ const row = db.prepare('SELECT * FROM journey_share_tokens WHERE token = ?').get(token) as any;
+ if (!row) return null;
+
+ const journey = db.prepare('SELECT * FROM journeys WHERE id = ?').get(row.journey_id) as any;
+ if (!journey) return null;
+
+ // Entries with photos
+ const entries = db.prepare(`
+ SELECT je.* FROM journey_entries je
+ WHERE je.journey_id = ? AND je.type != 'skeleton'
+ ORDER BY je.entry_date, je.sort_order
+ `).all(row.journey_id) as any[];
+
+ const photos = db.prepare(`
+ SELECT jp.* FROM journey_photos jp
+ JOIN journey_entries je ON jp.entry_id = je.id
+ WHERE je.journey_id = ?
+ ORDER BY jp.sort_order
+ `).all(row.journey_id) as any[];
+
+ const photosByEntry: Record = {};
+ for (const p of photos) {
+ (photosByEntry[p.entry_id] ||= []).push(p);
+ }
+
+ const enrichedEntries = entries.map(e => ({
+ ...e,
+ tags: e.tags ? JSON.parse(e.tags) : [],
+ pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
+ photos: photosByEntry[e.id] || [],
+ }));
+
+ // Stats
+ const stats = {
+ entries: entries.length,
+ photos: photos.length,
+ cities: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
+ };
+
+ return {
+ journey: {
+ title: journey.title,
+ subtitle: journey.subtitle,
+ cover_image: journey.cover_image,
+ status: journey.status,
+ },
+ entries: enrichedEntries,
+ stats,
+ permissions: {
+ share_timeline: !!row.share_timeline,
+ share_gallery: !!row.share_gallery,
+ share_map: !!row.share_map,
+ },
+ };
+}
diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts
index 9e3d32cf..fffe1592 100644
--- a/server/src/services/memories/helpersService.ts
+++ b/server/src/services/memories/helpersService.ts
@@ -123,6 +123,31 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
if (requestingUserId === ownerUserId) {
return true;
}
+
+ // Journey photos use tripId=0 — check journey_photos + journey_contributors
+ if (tripId === '0') {
+ const journeyPhoto = db.prepare(`
+ SELECT jp.entry_id, je.journey_id
+ FROM journey_photos jp
+ JOIN journey_entries je ON je.id = jp.entry_id
+ WHERE jp.asset_id = ?
+ AND jp.provider = ?
+ AND jp.owner_id = ?
+ LIMIT 1
+ `).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined;
+ if (!journeyPhoto) return false;
+
+ // Check if requesting user is the journey owner or a contributor
+ const access = db.prepare(`
+ SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
+ UNION ALL
+ SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?
+ LIMIT 1
+ `).get(journeyPhoto.journey_id, requestingUserId, journeyPhoto.journey_id, requestingUserId);
+ return !!access;
+ }
+
+ // Regular trip photos
const sharedAsset = db.prepare(`
SELECT 1
FROM trip_photos
diff --git a/server/src/services/memories/immichService.ts b/server/src/services/memories/immichService.ts
index 9f270062..047c732e 100644
--- a/server/src/services/memories/immichService.ts
+++ b/server/src/services/memories/immichService.ts
@@ -357,3 +357,63 @@ export async function syncAlbumAssets(
return { error: 'Could not reach Immich', status: 502 };
}
}
+
+// ── Upload to Immich ──────────────────────────────────────────────────────
+
+export async function uploadToImmich(userId: number, filePath: string, fileName: string): Promise {
+ const creds = getImmichCredentials(userId);
+ if (!creds) return null;
+
+ const fs = await import('node:fs');
+ const path = await import('node:path');
+
+ const fullPath = path.join(__dirname, '../../../uploads', filePath);
+ if (!fs.existsSync(fullPath)) return null;
+
+ try {
+ const fileBuffer = fs.readFileSync(fullPath);
+ const boundary = '----ImmichUpload' + Date.now();
+ const ext = path.extname(fileName).toLowerCase();
+ const mimeTypes: Record = {
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
+ '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic',
+ };
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
+ const now = new Date().toISOString();
+
+ const parts: Buffer[] = [];
+ const addField = (name: string, value: string) => {
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`));
+ };
+ addField('deviceAssetId', `trek-${Date.now()}`);
+ addField('deviceId', 'TREK');
+ addField('fileCreatedAt', now);
+ addField('fileModifiedAt', now);
+
+ parts.push(Buffer.from(
+ `--${boundary}\r\nContent-Disposition: form-data; name="assetData"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`
+ ));
+ parts.push(fileBuffer);
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
+
+ const body = Buffer.concat(parts);
+
+ const res = await safeFetch(`${creds.immich_url}/api/assets`, {
+ method: 'POST',
+ headers: {
+ 'x-api-key': creds.immich_api_key,
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
+ 'Content-Length': String(body.length),
+ },
+ body,
+ });
+
+ if (res.ok) {
+ const data = await res.json() as { id?: string };
+ return data.id || null;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts
index efdf5c94..1937854d 100644
--- a/server/src/services/tripService.ts
+++ b/server/src/services/tripService.ts
@@ -259,6 +259,18 @@ export function deleteTrip(tripId: string | number, userId: number, userRole: st
ownerEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email;
}
+ // Clean up journey entries synced from this trip before deleting
+ // Delete skeleton entries (unfilled synced places)
+ db.prepare(`
+ DELETE FROM journey_entries
+ WHERE source_trip_id = ? AND type = 'skeleton'
+ `).run(tripId);
+ // Detach filled entries (keep user's written content, just remove trip link)
+ db.prepare(`
+ UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL
+ WHERE source_trip_id = ?
+ `).run(tripId);
+
db.prepare('DELETE FROM trips WHERE id = ?').run(tripId);
return { tripId: Number(tripId), title: trip.title, ownerId: trip.user_id, isAdminDelete, ownerEmail };
diff --git a/server/src/types.ts b/server/src/types.ts
index 62808526..477248be 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -301,3 +301,69 @@ export interface Participant {
username: string;
avatar?: string | null;
}
+
+// ── Journey addon ─────────────────────────────────────────────────────────
+
+export interface Journey {
+ id: number;
+ user_id: number;
+ title: string;
+ subtitle?: string | null;
+ cover_gradient?: string | null;
+ cover_image?: string | null;
+ status: 'draft' | 'active' | 'completed';
+ created_at: number;
+ updated_at: number;
+}
+
+export interface JourneyEntry {
+ id: number;
+ journey_id: number;
+ source_trip_id?: number | null;
+ source_place_id?: number | null;
+ author_id: number;
+ type: 'entry' | 'checkin' | 'skeleton';
+ title?: string | null;
+ story?: string | null;
+ entry_date: string;
+ entry_time?: string | null;
+ location_name?: string | null;
+ location_lat?: number | null;
+ location_lng?: number | null;
+ mood?: string | null;
+ weather?: string | null;
+ tags?: string | null;
+ visibility: 'private' | 'shared' | 'public';
+ sort_order: number;
+ created_at: number;
+ updated_at: number;
+}
+
+export interface JourneyPhoto {
+ id: number;
+ entry_id: number;
+ provider: 'local' | 'immich' | 'synologyphotos';
+ asset_id?: string | null;
+ owner_id?: number | null;
+ file_path?: string | null;
+ thumbnail_path?: string | null;
+ caption?: string | null;
+ sort_order: number;
+ width?: number | null;
+ height?: number | null;
+ shared: number;
+ created_at: number;
+}
+
+export interface JourneyTrip {
+ journey_id: number;
+ trip_id: number;
+ added_at: number;
+}
+
+export interface JourneyContributor {
+ journey_id: number;
+ user_id: number;
+ role: 'owner' | 'editor' | 'viewer';
+ added_at: number;
+}