diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 41a6da26..16afe83e 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -190,7 +190,9 @@ export default function JourneyDetailPage() { const winner = lastPast || firstAhead if (winner) { setActiveEntryId(winner.id) - mapRef.current?.highlightMarker(winner.id) + if (locatedEntryIdsRef.current.has(winner.id)) { + mapRef.current?.highlightMarker(winner.id) + } } } const onScroll = () => { @@ -282,11 +284,16 @@ export default function JourneyDetailPage() { ) 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 uniqueDates = [...new Set(sorted.map(e => e.entry_date))] const dayCounters = new Map() return sorted.map(e => { - const dayIdx = uniqueDates.indexOf(e.entry_date) + const dayIdx = allDates.indexOf(e.entry_date) const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1 dayCounters.set(e.entry_date, dayLabel) return { @@ -302,7 +309,12 @@ export default function JourneyDetailPage() { dayLabel, } }) - }, [mapEntries]) + }, [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() diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index f98af73e..b7a9772a 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo } from 'react' +import { useEffect, useState, useMemo, useRef, useCallback } from 'react' import { useParams } from 'react-router-dom' import { journeyApi } from '../api/client' import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n' @@ -10,6 +10,7 @@ import { ThumbsUp, ThumbsDown, } from 'lucide-react' import JourneyMap from '../components/Journey/JourneyMap' +import type { JourneyMapHandle } from '../components/Journey/JourneyMap' import JournalBody from '../components/Journey/JournalBody' import PhotoLightbox from '../components/Journey/PhotoLightbox' import MobileMapTimeline from '../components/Journey/MobileMapTimeline' @@ -93,6 +94,15 @@ export default function JourneyPublicPage() { const { t } = useTranslation() const [showLangPicker, setShowLangPicker] = useState(false) const locale = useSettingsStore(s => s.settings.language) || 'en' + const mapRef = useRef(null) + const [activeEntryId, setActiveEntryId] = useState(null) + + const handleMarkerClick = useCallback((entryId: string) => { + setActiveEntryId(entryId) + mapRef.current?.highlightMarker(entryId) + document.querySelector(`[data-entry-id="${entryId}"]`) + ?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }, []) useEffect(() => { if (!token) return @@ -119,12 +129,13 @@ export default function JourneyPublicPage() { ) const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries]) - // Map entries with day color/label for colored markers + // Map entries with day color/label for colored markers. + // dayIdx is derived from sortedDates (ALL timeline dates) so marker colors + // stay in sync with the timeline day headers even when some days have no locations. const sidebarMapItems = useMemo(() => { - const uniqueDates = [...new Set(mapEntries.map(e => e.entry_date).sort())] const counters = new Map() return mapEntries.map(e => { - const dayIdx = uniqueDates.indexOf(e.entry_date) + const dayIdx = sortedDates.indexOf(e.entry_date) const dayLabel = (counters.get(e.entry_date) ?? 0) + 1 counters.set(e.entry_date, dayLabel) return { @@ -139,7 +150,7 @@ export default function JourneyPublicPage() { dayLabel, } }) - }, [mapEntries]) + }, [mapEntries, sortedDates]) // Two-column desktop layout: timeline feed left + sticky map right const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map @@ -153,7 +164,7 @@ export default function JourneyPublicPage() { // When switching to desktop two-column, 'map' standalone tab no longer exists useEffect(() => { if (desktopTwoColumn && view === 'map') setView('timeline') - }, [desktopTwoColumn]) + }, [desktopTwoColumn, view]) if (loading) { return ( @@ -223,8 +234,18 @@ export default function JourneyPublicPage() { const hasProscons = prosArr.length > 0 || consArr.length > 0 const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })) + const isActive = activeEntryId === String(entry.id) return ( -
+
{ + if (!desktopTwoColumn) return + setActiveEntryId(String(entry.id)) + mapRef.current?.highlightMarker(String(entry.id)) + }} + style={isActive && desktopTwoColumn ? { outline: `2px solid ${dayColor}`, outlineOffset: '3px', borderRadius: '16px' } : undefined} + className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden"> {/* Photo area */} {photos.length === 1 && ( @@ -324,7 +345,7 @@ export default function JourneyPublicPage() { {/* Pros & Cons */} {hasProscons && ( -
+
0 && consArr.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}> {prosArr.length > 0 && (
@@ -476,19 +497,19 @@ export default function JourneyPublicPage() { {/* Content */} {desktopTwoColumn ? ( // ── Desktop two-column: scrollable timeline feed + sticky map ────────── -
+
{/* Left: feed */} -
+
{renderTabs(availableViews)} {view === 'timeline' && perms.share_timeline && renderTimeline()} {view === 'gallery' && perms.share_gallery && renderGallery()}
- {/* Right: sticky map */} + {/* Right: sticky map — matches auth page aside proportions */} @@ -536,7 +560,7 @@ export default function JourneyPublicPage() { {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && ( ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))} + mapEntries={sidebarMapItems as any} dark={document.documentElement.classList.contains('dark')} readOnly onEntryClick={() => {}}