import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { formatLocationName } from '../utils/formatters' import { createPortal } from 'react-dom' import { useParams, useNavigate } from 'react-router-dom' import { useJourneyStore } from '../store/journeyStore' import { useAuthStore } from '../store/authStore' import { useTranslation } from '../i18n' import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client' import { addListener, removeListener } from '../api/websocket' import Navbar from '../components/Layout/Navbar' import JourneyMap from '../components/Journey/JourneyMapAuto' import { DAY_COLORS } from '../components/Journey/dayColors' import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto' import JournalBody from '../components/Journey/JournalBody' import MarkdownToolbar from '../components/Journey/MarkdownToolbar' import PhotoLightbox from '../components/Journey/PhotoLightbox' import { useToast } from '../components/shared/Toast' import ConfirmDialog from '../components/shared/ConfirmDialog' import { ArrowLeft, RefreshCw, MoreHorizontal, Share2, Download, List, Grid, MapPin, Link, Copy, Clock, Package, Image, ChevronRight, UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil, Laugh, Smile, Meh, Annoyed, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronUp, ChevronDown, Eye, EyeOff, Archive, ArchiveRestore, } from 'lucide-react' import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileEntryView from '../components/Journey/MobileEntryView' import { useIsMobile } from '../hooks/useIsMobile' import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore' import { computeJourneyLifecycle } from '../utils/journeyLifecycle' 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] } const MOOD_CONFIG: Record = { amazing: { bg: '#FDF2F8', text: '#BE185D', icon: Laugh, label: 'journey.mood.amazing' }, good: { bg: '#FFFBEB', text: '#B45309', icon: Smile, label: 'journey.mood.good' }, neutral: { bg: '#F4F4F5', text: '#3F3F46', icon: Meh, label: 'journey.mood.neutral' }, rough: { bg: '#F5F3FF', text: '#6D28D9', icon: Frown, label: 'journey.mood.rough' }, } const WEATHER_CONFIG: Record = { sunny: { icon: Sun, label: 'journey.weather.sunny' }, partly: { icon: CloudSun, label: 'journey.weather.partly' }, cloudy: { icon: Cloud, label: 'journey.weather.cloudy' }, rainy: { icon: CloudRain, label: 'journey.weather.rainy' }, stormy: { icon: CloudLightning, label: 'journey.weather.stormy' }, cold: { icon: Snowflake, label: 'journey.weather.cold' }, } function groupByDate(entries: JourneyEntry[]): 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 } function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } { const date = new Date(d + 'T00:00:00') // Pass the app's selected locale so weekday/month follow the UI language // instead of the browser's navigator.language. return { weekday: date.toLocaleDateString(locale, { weekday: 'long' }), month: date.toLocaleDateString(locale, { month: 'long' }), day: date.getDate(), } } function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string { return `/api/photos/${p.photo_id}/${size}` } export default function JourneyDetailPage() { const { id } = useParams() const navigate = useNavigate() const toast = useToast() const { t, locale } = useTranslation() const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore() const mapRef = useRef(null) const fullMapRef = useRef(null) const [activeLocationId, setActiveLocationId] = useState(null) const isMobile = useIsMobile() // Role-based permissions (server-provided via my_role). Fall back to // "owner" when the field isn't present yet (legacy responses) so behavior // matches the pre-permissions era. const myRole = (current as any)?.my_role ?? 'owner' const canEditEntries = myRole === 'owner' || myRole === 'editor' const canEditJourney = myRole === 'owner' const [view, setView] = useState<'timeline' | 'gallery'>('timeline') const [activeEntryId, setActiveEntryId] = useState(null) const feedRef = useRef(null) const [viewingEntry, setViewingEntry] = useState(null) const [editingEntry, setEditingEntry] = useState(null) const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null) const [deleteTarget, setDeleteTarget] = useState(null) const [showInvite, setShowInvite] = useState(false) const [showAddTrip, setShowAddTrip] = useState(false) const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null) const [showSettings, setShowSettings] = useState(false) const [hideSkeletons, setHideSkeletons] = useState(false) useEffect(() => { if (id) loadJourney(Number(id)).catch(() => {}) }, [id]) useEffect(() => { if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons) }, [current?.hide_skeletons]) useEffect(() => { if (notFound) { toast.error(t('journey.notFound')) navigate('/journey') } }, [notFound]) // WebSocket real-time updates useEffect(() => { if (!id) return const journeyId = Number(id) const handler = (event: Record) => { const type = event.type as string if (!type?.startsWith('journey:')) return if (event.journeyId !== journeyId) return // reload journey data on any change from other contributors loadJourney(journeyId) } addListener(handler) return () => removeListener(handler) }, [id]) // scroll sync with map — the sticky map on the right follows whichever // entry the user is currently reading in the feed on the left. We use // scroll position (not IntersectionObserver) because short text-only // entries pass through any IO band too quickly to reliably register. const rafRef = useRef(null) const scrollCleanupRef = useRef<(() => void) | null>(null) // Suppress scroll-sync updates while a programmatic smooth-scroll is // running (triggered by a marker click). The scroll-progress reference // line doesn't align with `scrollIntoView({ block: 'center' })`, so the // sync would otherwise pick random entries as the scroll animates past // them and end up nowhere near the clicked marker. const suppressScrollSyncRef = useRef(false) const suppressTimerRef = useRef(null) const setupScrollSync = useCallback(() => { scrollCleanupRef.current?.() const feed = feedRef.current if (!feed) return const commitWinner = () => { if (suppressScrollSyncRef.current) return const nodes = document.querySelectorAll('[data-entry-id]') if (nodes.length === 0) return const feedRect = feed.getBoundingClientRect() // Reference line tracks scroll progress — at the top of the feed // it sits at the top edge; at the bottom it sits at the bottom // edge. This keeps every entry passing through the line exactly // once even when they're too short to cross a static line before // the feed runs out of scroll. const maxScroll = feed.scrollHeight - feed.clientHeight const progress = maxScroll > 0 ? feed.scrollTop / maxScroll : 0 const referenceY = feedRect.top + feedRect.height * progress let lastPast: { id: string; top: number } | null = null let firstAhead: { id: string; top: number } | null = null nodes.forEach(el => { const entryId = el.getAttribute('data-entry-id') if (!entryId) return const top = el.getBoundingClientRect().top if (top <= referenceY) { if (!lastPast || top > lastPast.top) lastPast = { id: entryId, top } } else { if (!firstAhead || top < firstAhead.top) firstAhead = { id: entryId, top } } }) const winner = lastPast || firstAhead if (winner) { setActiveEntryId(winner.id) if (locatedEntryIdsRef.current.has(winner.id)) { mapRef.current?.highlightMarker(winner.id) } } } const onScroll = () => { if (rafRef.current != null) return rafRef.current = window.requestAnimationFrame(() => { rafRef.current = null commitWinner() }) } feed.addEventListener('scroll', onScroll, { passive: true }) window.addEventListener('scroll', onScroll, { passive: true }) // prime once so the map syncs on initial load commitWinner() scrollCleanupRef.current = () => { feed.removeEventListener('scroll', onScroll) window.removeEventListener('scroll', onScroll) if (rafRef.current != null) { window.cancelAnimationFrame(rafRef.current) rafRef.current = null } } }, []) useEffect(() => { if (current?.entries?.length) { const t = window.setTimeout(setupScrollSync, 300) return () => { window.clearTimeout(t) scrollCleanupRef.current?.() } } return () => scrollCleanupRef.current?.() }, [current?.entries, setupScrollSync]) const handleMarkerClick = useCallback((entryId: string) => { const el = document.querySelector(`[data-entry-id="${entryId}"]`) if (!el) return // Commit the choice immediately so the highlighted marker stays pinned // to the clicked entry even while smooth-scroll passes over others. suppressScrollSyncRef.current = true setActiveEntryId(entryId) mapRef.current?.highlightMarker(entryId) el.scrollIntoView({ behavior: 'smooth', block: 'center' }) if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current) // Smooth scroll typically finishes within ~500ms; 750ms gives a safety // buffer so the sync doesn't snap back to the wrong entry on the very // last frame. suppressTimerRef.current = window.setTimeout(() => { suppressScrollSyncRef.current = false suppressTimerRef.current = null }, 750) }, []) useEffect(() => () => { if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current) }, []) const handleLocationClick = useCallback((id: string) => { setActiveLocationId(id) }, []) useEffect(() => { // give the sidebar map a chance to recalc its size when the view switches // (feed column width can shift slightly if the gallery vs timeline // renders with a different scrollbar state). requestAnimationFrame(() => mapRef.current?.invalidateSize()) }, [view]) // On desktop we run a two-pane layout where only the feed column scrolls; // the body must not scroll underneath it. Restore on unmount. useEffect(() => { if (isMobile) return const prev = document.body.style.overflow document.body.style.overflow = 'hidden' return () => { document.body.style.overflow = prev } }, [isMobile]) // Map only shows real journal entries — skeletons are trip-derived // suggestions, not something the user actually journaled at that spot. const mapEntries = useMemo( () => (current?.entries || []).filter(e => e.location_lat && e.location_lng && e.title !== 'Gallery' && e.title !== '[Trip Photos]' && e.type !== 'skeleton' ), [current?.entries] ) const sidebarMapItems = useMemo(() => { const allDates = [...new Set( (current?.entries || []) .filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]') .map(e => e.entry_date) .sort() )] const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date)) const dayCounters = new Map() return sorted.map(e => { const dayIdx = allDates.indexOf(e.entry_date) const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1 dayCounters.set(e.entry_date, dayLabel) return { id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title || '', location_name: e.location_name || '', mood: e.mood, created_at: e.entry_date, entry_date: e.entry_date, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length], dayLabel, } }) }, [mapEntries, current?.entries]) const locatedEntryIdsRef = useRef(new Set()) useEffect(() => { locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id)) }, [sidebarMapItems]) const tripDates = useMemo(() => { const dates = new Set() if (!current?.trips) return dates for (const trip of current.trips) { if (!trip.start_date || !trip.end_date) continue const start = new Date(trip.start_date + 'T00:00:00') const end = new Date(trip.end_date + 'T00:00:00') for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { dates.add(d.toISOString().split('T')[0]) } } return dates }, [current?.trips]) if (loading || !current) { return (
) } const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton')) const dayGroups = groupByDate(timelineEntries) const sortedDates = [...dayGroups.keys()].sort() const tripDateMin = current.trips.length ? current.trips.reduce((min: string, t: any) => t.start_date && (!min || t.start_date < min) ? t.start_date : min, '') : null const tripDateMax = current.trips.length ? current.trips.reduce((max: string, t: any) => t.end_date && (!max || t.end_date > max) ? t.end_date : max, '') : null const lifecycle = computeJourneyLifecycle(current.status, tripDateMin || null, tripDateMax || null) const showMobileCombined = isMobile && view === 'timeline' const showMobileGallery = isMobile && view === 'gallery' const isMobileChromeless = showMobileCombined || showMobileGallery return (
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */} {showMobileCombined && ( setViewingEntry(entry)} onAddEntry={canEditEntries ? () => { const today = new Date().toISOString().split('T')[0] setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry) } : undefined} /> )} {/* Fullscreen entry view (mobile) */} {viewingEntry && ( setViewingEntry(null)} onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }} onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }} onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} /> )} {/* Floating top bar on mobile Journey + Gallery views: back | tabs+title | settings */} {isMobileChromeless && (
{canEditJourney ? ( ) : (
)}
)}
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
{/* Hero card — hidden on mobile gallery/journey views (floating top bar handles branding there) */}
{current.cover_image && (
)}
{/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
{lifecycle !== 'live' && lifecycle !== 'archived' && (
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
)} {lifecycle === 'archived' && (
{t('journey.status.archived')}
)}
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
{canEditJourney && ( )}

{current.title}

{current.subtitle &&

{current.subtitle}

}
{[ { value: sortedDates.length, label: t('journey.stats.days') }, { value: current.stats.places, label: t('journey.stats.places') }, { value: current.stats.entries, label: t('journey.stats.entries') }, { value: current.stats.photos, label: t('journey.stats.photos') }, ].map(s => (
{s.value} {s.label}
))}
{/* Main content (was a 2-col grid with right-sidebar panels; now single column inside the left feed — right pane is a sticky fullscreen map further below). */}
{/* View Controls — hidden on mobile (floating top bar has them) */}
{(isMobile ? [ { id: 'timeline' as const, icon: MapPin, label: t('journey.detail.journeyTab') || 'Journey' }, { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, ] : [ { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, ] ).map(v => ( ))}
{canEditEntries && (!isMobile ? view === 'timeline' : view !== 'gallery') && ( )}
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */} {!isMobile && (
{sortedDates.length === 0 && (

No entries yet

Add a trip to get started with skeleton entries

)} {sortedDates.map((date, dayIdx) => { const entries = dayGroups.get(date)! const fd = formatDate(date, locale) const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))] return (
{dayIdx + 1}

{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}

{entries.length} {t('journey.synced.places')}
{entries.map((entry, idx) => { // Skeletons are just "suggested" places pulled // from the linked trip — they aren't real // journey entries until the user edits them, // so reordering them does not make sense. const canReorder = !isMobile && canEditEntries && entries.length > 1 && entry.type !== 'skeleton' const move = (direction: -1 | 1) => { if (!current) return const target = idx + direction if (target < 0 || target >= entries.length) return const reordered = [...entries] const [moved] = reordered.splice(idx, 1) reordered.splice(target, 0, moved) reorderEntries(current.id, reordered.map(e => e.id)) .catch(() => toast.error(t('common.errorOccurred'))) } return (
{ setActiveEntryId(String(entry.id)); mapRef.current?.highlightMarker(String(entry.id)) }} style={String(entry.id) === activeEntryId ? { outline: `2px solid ${DAY_COLORS[dayIdx % DAY_COLORS.length]}`, outlineOffset: '3px', borderRadius: '12px' } : undefined}> {canReorder && (
)}
{entry.type === 'skeleton' ? ( setEditingEntry(entry) : undefined} /> ) : entry.type === 'checkin' ? ( setEditingEntry(entry) : undefined} /> ) : ( setEditingEntry(entry)} onDelete={() => setDeleteTarget(entry)} onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} /> )}
) })}
) })}
)} {/* Gallery View — mobile gets extra top padding so the floating top bar doesn't overlap */}
setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} onRefresh={() => loadJourney(Number(id))} />
{/* RIGHT column on desktop — sticky rounded map (polarsteps-style). Hidden on mobile; mobile gets its own chromeless combined view. */} {!isMobile && ( )}
{/* Entry Editor */} {editingEntry && ( e.photos || [])} onClose={() => setEditingEntry(null)} onSave={async (data) => { let entryId = editingEntry.id if (editingEntry.id === 0) { const created = await useJourneyStore.getState().createEntry(current.id, data) entryId = created.id } else { await updateEntry(editingEntry.id, data) } return entryId }} onUploadPhotos={async (entryId, formData) => { return await uploadPhotos(entryId, formData) }} onDone={() => { setEditingEntry(null) loadJourney(Number(id)) }} /> )} {/* Journey Settings */} {showSettings && ( setShowSettings(false)} onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }} onOpenInvite={() => { setShowInvite(true) }} onRefresh={() => loadJourney(Number(id))} /> )} {/* Add Trip Dialog */} {showAddTrip && current && ( t.trip_id)} onClose={() => setShowAddTrip(false)} onAdded={() => { setShowAddTrip(false); loadJourney(Number(id)) }} /> )} {/* Contributor Invite Dialog */} {showInvite && ( c.user_id)} onClose={() => setShowInvite(false)} onInvited={() => { setShowInvite(false); loadJourney(Number(id)) }} /> )} {/* Delete confirm */} setDeleteTarget(null)} onConfirm={async () => { if (!deleteTarget) return await deleteEntry(deleteTarget.id) setDeleteTarget(null) loadJourney(Number(id)) }} title={t('journey.entries.deleteTitle')} message={t('journey.deleteConfirmMessage', { title: deleteTarget?.title || 'this entry' })} confirmLabel={t('common.delete')} danger /> {/* Unlink Trip confirm */} setUnlinkTrip(null)} onConfirm={async () => { if (!unlinkTrip || !current) return try { await journeyApi.removeTrip(current.id, unlinkTrip.trip_id) toast.success(t('journey.trips.tripUnlinked')) setUnlinkTrip(null) loadJourney(Number(id)) } catch { toast.error(t('journey.trips.unlinkFailed')) } }} title={t('journey.trips.unlinkTrip')} message={t('journey.trips.unlinkMessage', { title: unlinkTrip?.title })} confirmLabel={t('journey.trips.unlink')} danger /> {/* Lightbox */} {lightbox && ( ({ id: p.id.toString(), src: p.src, caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id }))} startIndex={lightbox.index} onClose={() => setLightbox(null)} /> )}
) } // ── Map View ────────────────────────────────────────────────────────────── function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRef, onLocationClick }: { entries: JourneyEntry[] mapEntries: JourneyEntry[] sortedDates: string[] activeLocationId: string | null fullMapRef: React.RefObject onLocationClick: (id: string) => void }) { const { t, locale } = useTranslation() // group map entries by date const byDate = new Map() mapEntries.forEach((e, i) => { const d = e.entry_date if (!byDate.has(d)) byDate.set(d, []) byDate.get(d)!.push({ entry: e, globalIdx: i }) }) const dates = [...byDate.keys()].sort() // find first and last entry indices const firstId = mapEntries[0]?.id const lastId = mapEntries[mapEntries.length - 1]?.id const mapItems = useMemo(() => mapEntries.map(e => ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title || '', mood: e.mood, entry_date: e.entry_date, })), [mapEntries]) return (
{/* Locations list */}
{/* Stats header */} {mapEntries.length > 0 && (
{[ { value: mapEntries.length, label: t('journey.stats.places') }, { value: dates.length, label: t('journey.stats.days') }, { value: entries.filter(e => e.type === 'entry').length, label: 'Stories' }, ].map(s => (
{s.value}
{s.label}
))}
)} {/* Day groups */}
{dates.map((date, dayIdx) => { const items = byDate.get(date)! const fd = formatDate(date, locale) return (
{/* Day separator */}
{t('journey.detail.day', { number: dayIdx + 1 })} {fd.month} {fd.day}
{/* Location items */} {items.map(({ entry: e, globalIdx }, itemIdx) => { const isActive = activeLocationId === String(e.id) const isFirst = e.id === firstId const isLast = e.id === lastId const showConnector = itemIdx < items.length - 1 return (
onLocationClick(String(e.id))} className={`flex items-center gap-3 p-3 rounded-[14px] cursor-pointer transition-all ${ isActive ? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-zinc-100 translate-x-0.5' : 'bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:translate-x-0.5' }`} > {/* Number badge */}
{globalIdx + 1}
{/* Info */}
{e.title || e.location_name}
{formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''}
{/* Chevron */}
{/* Connector line */} {showConnector && (
)}
) })}
) })}
) } // ── Gallery View ────────────────────────────────────────────────────────── function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: { entries: JourneyEntry[] journeyId: number userId: number trips: JourneyTrip[] onPhotoClick: (photos: JourneyPhoto[], index: number) => void onRefresh: () => void }) { const { t } = useTranslation() const [showPicker, setShowPicker] = useState(false) const [pickerProvider, setPickerProvider] = useState(null) const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([]) const [galleryUploading, setGalleryUploading] = useState(false) const toast = useToast() // check which providers are enabled AND connected for the current user useEffect(() => { (async () => { try { const addonsData = await addonsApi.enabled() const enabledProviders = (addonsData.addons || []).filter( (a: any) => a.type === 'photo_provider' && a.enabled ) const connected: { id: string; name: string }[] = [] for (const p of enabledProviders) { try { const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' }) if (res.ok) { const status = await res.json() if (status.connected) connected.push({ id: p.id, name: p.name }) } } catch {} } setAvailableProviders(connected) } catch {} })() }, []) const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = [] const seenPhotoIds = new Map() // photo_id → index in allPhotos for (const e of entries) { for (const p of e.photos) { const existing = seenPhotoIds.get(p.photo_id) if (existing === undefined) { seenPhotoIds.set(p.photo_id, allPhotos.length) allPhotos.push({ photo: p, entry: e }) } else if (e.title === 'Gallery' && allPhotos[existing].entry.title !== 'Gallery') { allPhotos[existing] = { photo: p, entry: e } } } } const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title) const browseProvider = (provider: string) => { setPickerProvider(provider) setShowPicker(true) } const galleryFileRef = useRef(null) const handleGalleryUpload = async (e: React.ChangeEvent) => { const files = e.target.files if (!files?.length) return setGalleryUploading(true) try { // find existing "Gallery" entry or create one. The stored title is the // literal 'Gallery' (server-side checks look for this exact string) — // do not send a translated label here. let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry') let entryId = galleryEntry?.id if (!entryId) { const entry = await journeyApi.createEntry(journeyId, { title: 'Gallery', entry_date: new Date().toISOString().split('T')[0], type: 'entry', }) entryId = entry.id } const formData = new FormData() for (const f of files) formData.append('photos', f) await journeyApi.uploadPhotos(entryId, formData) toast.success(t('journey.photosUploaded', { count: files.length })) onRefresh() } catch { toast.error(t('journey.settings.coverFailed')) } finally { setGalleryUploading(false) } e.target.value = '' } const handleDeletePhoto = async (photoId: number) => { const store = useJourneyStore.getState() if (!store.current) return const target = store.current.entries.flatMap(e => e.photos).find(p => p.id === photoId) if (!target) return const siblingIds = store.current.entries.flatMap(e => e.photos).filter(p => p.photo_id === target.photo_id).map(p => p.id) // Optimistic update — remove every row with this photo_id const updated = { ...store.current, entries: store.current.entries.map(e => ({ ...e, photos: e.photos.filter(p => p.photo_id !== target.photo_id), })).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story), } useJourneyStore.setState({ current: updated }) try { await Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id))) } catch { toast.error(t('common.error')) onRefresh() } } return (
{/* Header */}
{allPhotos.length} {t('journey.detail.photos')}
{availableProviders.map(p => ( ))}
{allPhotos.length === 0 ? (

{t('journey.detail.noPhotos')}

{t('journey.detail.noPhotosHint')}

) : (
{allPhotos.map(({ photo, entry }, i) => (
onPhotoClick(allPhotos.map(a => a.photo), i)} > {photo.caption
{/* Delete button */} {photo.provider && photo.provider !== 'local' && (
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
)} {photo.caption && (

{photo.caption}

)}
{new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
))}
)} {/* Provider Photo Picker Modal */} {showPicker && ( (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))} onClose={() => setShowPicker(false)} onAdd={async (groups, entryId) => { let targetId = entryId if (!targetId) { try { const entry = await journeyApi.createEntry(journeyId, { title: 'Gallery', entry_date: new Date().toISOString().split('T')[0], type: 'entry', }) targetId = entry.id } catch { return } } let added = 0 for (const group of groups) { try { const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase) added += result.added || 0 } catch {} } if (added > 0) { toast.success(t('journey.photosAdded', { count: added })) onRefresh() } setShowPicker(false) }} /> )}
) } // ── Expandable Story ───────────────────────────────────────────────────── function ExpandableStory({ story }: { story: string }) { const { t } = useTranslation() const [expanded, setExpanded] = useState(false) const [clamped, setClamped] = useState(false) const ref = useRef(null) const measuredRef = useRef(false) useEffect(() => { measuredRef.current = false }, [story]) useEffect(() => { if (measuredRef.current) return const el = ref.current if (el && !expanded) { setClamped(el.scrollHeight > el.clientHeight) measuredRef.current = true } }) return (
{ if (clamped || expanded) setExpanded(e => !e) }} className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${ expanded ? '' : 'line-clamp-3 md:line-clamp-[9]' } ${clamped || expanded ? 'cursor-pointer' : ''}`} >
{clamped && !expanded && ( )} {expanded && ( )}
) } // ── Verdict Section (Pros & Cons) ──────────────────────────────────────── function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) { const { t } = useTranslation() const [open, setOpen] = useState(false) // On desktop always show, on mobile toggle return (
{/* Header — clickable on mobile */} {/* Collapsed summary on mobile */} {!open && (
{pros.length > 0 && (
{pros.length}
)} {cons.length > 0 && (
{cons.length}
)}
)} {/* Content — always visible on desktop, toggled on mobile */}
{pros.length > 0 && (
{t('journey.verdict.lovedIt')} {pros.length}
{pros.map((p, i) => (
{p}
))}
)} {cons.length > 0 && (
{t('journey.verdict.couldBeBetter')} {cons.length}
{cons.map((c, i) => (
{c}
))}
)}
) } // ── Entry Card ──────────────────────────────────────────────────────────── function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: { entry: JourneyEntry readOnly?: boolean onEdit: () => void onDelete: () => void onPhotoClick: (photos: JourneyPhoto[], index: number) => void }) { const { t } = useTranslation() const [menuOpen, setMenuOpen] = useState(false) const menuBtnRef = useRef(null) const photos = entry.photos || [] const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null const prosArr = entry.pros_cons?.pros ?? [] const consArr = entry.pros_cons?.cons ?? [] const hasProscons = prosArr.length > 0 || consArr.length > 0 return (
{/* Hero area: photos with title overlay */} {photos.length > 0 ? (
onPhotoClick(photos, idx)} /> {/* Gradient overlay for title */}
{/* Badges top-left */}
{entry.location_name && ( {formatLocationName(entry.location_name)} )} {entry.entry_time && ( {entry.entry_time} )}
{/* Menu top-right */} {!readOnly && (
{menuOpen && createPortal( <>
setMenuOpen(false)} />
, document.body, )}
)} {/* Title on photo */} {entry.title && (

{entry.title}

)}
) : ( /* No photos: simple header */
{entry.location_name && ( {formatLocationName(entry.location_name)} )} {entry.entry_time && ( {entry.entry_time} )}
{!readOnly && (
{menuOpen && createPortal( <>
setMenuOpen(false)} />
, document.body, )}
)}
)}
{/* Title (only if no photos — otherwise shown on image) */} {!photos.length && entry.title && (

{entry.title}

)} {!photos.length && entry.location_name && !entry.title && (
)} {entry.story && ( )} {/* Pros & Cons — "Pros & Cons" style */} {hasProscons && ( )} {(mood || weather || (entry.tags && entry.tags.length > 0)) && (
{mood && } {weather && }
{entry.tags?.map((tag, i) => ( {tag} ))}
)}
) } function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) { const { t } = useTranslation() return (
{entry.title || t('journey.detail.newEntry')}
{formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
{t('journey.detail.addEntry')} →
) } function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) { return (
{entry.title} {entry.location_name && · {entry.location_name}}
{entry.story &&
{entry.story}
}
{entry.entry_time && {entry.entry_time}}
) } function PhotoImg({ photo, className, style, onClick }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties; onClick?: () => void }) { const src = photoUrl(photo, 'thumbnail') return ( ) } function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick: (idx: number) => void }) { const count = photos.length if (count === 0) return null if (count === 1) { return (
onClick(0)}>
) } if (count === 2) { return (
{photos.slice(0, 2).map((p, i) => ( onClick(i)} /> ))}
) } return (
onClick(0)}>
onClick(1)}>
onClick(2)}> {count > 3 && (
+{count - 3}
)}
) } function MoodChip({ mood }: { mood: string }) { const { t } = useTranslation() const config = MOOD_CONFIG[mood] if (!config) return null const Icon = config.icon return (
{t(config.label)}
) } function WeatherChip({ weather }: { weather: string }) { const { t } = useTranslation() const config = WEATHER_CONFIG[weather] if (!config) return null const Icon = config.icon return (
{t(config.label)}
) } // ── Scroll Trigger ─────────────────────────────────────────────────────── function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading: boolean }) { const ref = useRef(null) useEffect(() => { const el = ref.current if (!el) return const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisible() }, { rootMargin: '200px' }) obs.observe(el) return () => obs.disconnect() }, [onVisible, loading]) return (
) } // ── Photo date grouping ─────────────────────────────────────────────────── function groupPhotosByDate(photos: any[]): { date: string; label: string; assets: any[] }[] { const map = new Map() for (const asset of photos) { const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__' if (!map.has(key)) map.set(key, []) map.get(key)!.push(asset) } return [...map.entries()].map(([date, assets]) => ({ date, label: date === '__unknown__' ? 'Unknown date' : new Date(date + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }), assets, })) } // ── Provider Picker ─────────────────────────────────────────────────────── function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: { provider: string userId: number entries: JourneyEntry[] trips: JourneyTrip[] existingAssetIds: Set onClose: () => void onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise }) { const { t } = useTranslation() const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip') const [photos, setPhotos] = useState([]) const [albums, setAlbums] = useState>([]) const [selectedAlbum, setSelectedAlbum] = useState(null) const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState(undefined) const [loading, setLoading] = useState(false) const [loadingMore, setLoadingMore] = useState(false) const [hasMore, setHasMore] = useState(false) const [searchPage, setSearchPage] = useState(1) const [searchFrom, setSearchFrom] = useState('') const [searchTo, setSearchTo] = useState('') const [selected, setSelected] = useState>(new Map()) const [customFrom, setCustomFrom] = useState('') const [customTo, setCustomTo] = useState('') const [targetEntryId, setTargetEntryId] = useState(null) const [addToOpen, setAddToOpen] = useState(false) const abortRef = useRef(null) const gridRef = useRef(null) // compute trip range const tripRange = useMemo(() => { let from = '', to = '' for (const t of trips) { if (t.start_date && (!from || t.start_date < from)) from = t.start_date if (t.end_date && (!to || t.end_date > to)) to = t.end_date } return { from, to } }, [trips]) const cancelPending = () => { if (abortRef.current) { abortRef.current.abort() } abortRef.current = new AbortController() return abortRef.current.signal } const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => { const signal = cancelPending() if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) } setSearchFrom(from) setSearchTo(to) setSearchPage(page) try { const res = await fetch(`/api/integrations/memories/${provider}/search`, { method: 'POST', credentials: 'include', signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from, to, page, size: 50 }), }) if (res.ok) { const data = await res.json() const assets = data.assets || [] setPhotos(prev => append ? [...prev, ...assets] : assets) setHasMore(!!data.hasMore) } else { setHasMore(false) } } catch (e: any) { if (e.name !== 'AbortError') setHasMore(false) } if (!signal.aborted) { setLoading(false); setLoadingMore(false) } } const loadMorePhotos = () => { if (loadingMore || !hasMore) return searchPhotos(searchFrom, searchTo, searchPage + 1, true) } const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => { const signal = cancelPending() setLoading(true) setPhotos([]) setHasMore(false) try { const qs = album.passphrase ? `?passphrase=${encodeURIComponent(album.passphrase)}` : '' const res = await fetch(`/api/integrations/memories/${provider}/albums/${album.id}/photos${qs}`, { credentials: 'include', signal }) if (res.ok) setPhotos((await res.json()).assets || []) } catch (e: any) { if (e.name !== 'AbortError') {} } if (!signal.aborted) setLoading(false) } const loadAlbums = async () => { try { const res = await fetch(`/api/integrations/memories/${provider}/albums`, { credentials: 'include' }) if (res.ok) setAlbums((await res.json()).albums || []) } catch {} } // load on mount / filter change useEffect(() => { if (filter === 'trip' && tripRange.from && tripRange.to) { searchPhotos(tripRange.from, tripRange.to) } else if (filter === 'all') { searchPhotos('', '') } else if (filter === 'album' && albums.length === 0) { loadAlbums() } }, [filter]) const handleCustomSearch = () => { if (customFrom && customTo) searchPhotos(customFrom, customTo) } const toggleAsset = (id: string) => { setSelected(prev => { const next = new Map(prev) if (next.has(id)) { next.delete(id) } else { next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }) } return next }) } const targetLabel = targetEntryId ? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries') : t('journey.picker.newGallery') return (
{ if (e.target === e.currentTarget) e.preventDefault() }}>
e.stopPropagation()}> {/* Header */}

{provider === 'immich' ? 'Immich' : 'Synology Photos'}

{/* Filter bar */}
{/* Tabs */}
{[ { id: 'trip' as const, label: t('journey.picker.tripPeriod') }, { id: 'custom' as const, label: t('journey.picker.dateRange') }, { id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') }, { id: 'album' as const, label: t('journey.picker.albums') }, ].map(f => ( ))}
{/* Filter content — always visible row */}
{filter === 'trip' && (
{tripRange.from && tripRange.to ? ( <> {new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} {new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} ({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days) ) : ( {t('journey.trips.noTripsLinkedSettings')} )}
)} {filter === 'custom' && (
)} {filter === 'album' && (
{albums.map((a: any) => ( ))} {albums.length === 0 && !loading && {t('journey.picker.noAlbums')}}
)}
{/* Add-to entry selector */}
{t('journey.picker.addTo')} {addToOpen && ( <>
setAddToOpen(false)} />
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
)} {entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => ( ))}
)}
{/* Select all bar — sticky above grid */} {!loading && photos.length > 0 && (() => { const selectable = photos.filter((a: any) => !existingAssetIds.has(a.id)) const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id)) if (selectable.length === 0) return null return (
) })()} {/* Photo grid */}
{loading ? (
) : photos.length === 0 ? (

{filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')}

) : (
{groupPhotosByDate(photos).map(group => (

{group.label}

{group.assets.map((asset: any) => { const isSelected = selected.has(asset.id) const alreadyAdded = existingAssetIds.has(asset.id) return (
!alreadyAdded && toggleAsset(asset.id)} className={`relative aspect-square rounded-lg overflow-hidden ${ alreadyAdded ? 'opacity-40 cursor-not-allowed' : isSelected ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer' : 'cursor-pointer' }`} > { const img = e.currentTarget const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}` if (!img.src.includes('/original')) img.src = original }} /> {alreadyAdded && (
)} {isSelected && !alreadyAdded && (
)} {asset.city && (

{asset.city}

)}
) })}
))} {/* Infinite scroll trigger */} {hasMore && !selectedAlbum && }
)}
{/* Footer */}
{selected.size} {t('journey.picker.selected')}
) } // ── Date Picker ─────────────────────────────────────────────────────────── function DatePicker({ value, onChange, tripDates }: { value: string onChange: (date: string) => void tripDates?: Set }) { const { t } = useTranslation() 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(undefined, { 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(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null return (
{open && ( <>
setOpen(false)} />
{/* Month nav */}
{monthName}
{/* Weekday headers */}
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d, i) => (
{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 isMobile = useIsMobile() 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 [uploading, setUploading] = 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) // Track which fields differ from the entry we started editing so we can // warn before discarding on close/cancel. const originalPros = (entry.pros_cons?.pros ?? []).join('\n') const originalCons = (entry.pros_cons?.cons ?? []).join('\n') const isDirty = ( title !== (entry.title || '') || story !== (entry.story || '') || entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) || entryTime !== (entry.entry_time || '') || locationName !== (entry.location_name || '') || (locationLat ?? null) !== (entry.location_lat ?? null) || (locationLng ?? null) !== (entry.location_lng ?? null) || mood !== (entry.mood || '') || weather !== (entry.weather || '') || pros.filter(p => p.trim()).join('\n') !== originalPros || cons.filter(c => c.trim()).join('\n') !== originalCons || pendingFiles.length > 0 || pendingLinkIds.length > 0 ) const uniqueGalleryPhotos = Array.from(new Map(galleryPhotos.map(gp => [gp.photo_id, gp])).values()) const availableGalleryPhotos = uniqueGalleryPhotos.filter(gp => !photos.some(p => p.photo_id === gp.photo_id)) const handleClose = () => { if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return onClose() } 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() || pendingFiles.length > 0 || pendingLinkIds.length > 0)) ? '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 // Queue files locally until Save so cancel/close actually discards. This // keeps photo behavior consistent with text fields — no silent persistence. setPendingFiles(prev => [...prev, ...Array.from(files)]) } return (
{/* The modal itself is constrained to the feed column on desktop so it centers there — but the backdrop stays full-width (covering the map too) for a uniform dim/blur across the whole page. */}

{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. Safari collapses `aspect-square` items inside an overflow-scroll grid, so the square is enforced with a padding-top spacer + an absolutely positioned image (works across all browsers). */} {showGalleryPick && (
{availableGalleryPhotos.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="relative w-full 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" style={{ paddingTop: '100%' }} > { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
))} {availableGalleryPhotos.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' : ''}`}> { 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) => (
))}
)}