From 0f44d7d264ca805ef6ffb3d6a887f397205a1f34 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 16 Apr 2026 23:37:09 +0200 Subject: [PATCH] feat(journey): combined map+timeline view on mobile (Polarsteps-style) Merge the separate Timeline and Map tabs into a single fullscreen combined view on mobile (<1024px). A Leaflet map fills the background while a horizontal snap-scroll carousel of entry cards sits at the bottom. Scrolling the carousel auto-focuses the corresponding map marker; tapping a marker scrolls to the card. Tapping a card opens a new fullscreen entry view with edit/delete actions. - New: MobileMapTimeline, MobileEntryCard, MobileEntryView components - New: useIsMobile hook (matchMedia < 1024px) - JourneyMap: fullScreen + paddingBottom props, focusMarker guard - Desktop layout completely unchanged - Public share page gets the same combined view (read-only) - Fix: entry editor now portaled to body (iOS stacking context) - Fix: pros/cons dark mode input backgrounds - Fix: mood button borders in dark mode - Fix: location icon color (neutral instead of green/indigo) --- client/src/components/Journey/JourneyMap.tsx | 32 ++- .../components/Journey/MobileEntryCard.tsx | 154 +++++++++++++ .../components/Journey/MobileEntryView.tsx | 218 ++++++++++++++++++ .../components/Journey/MobileMapTimeline.tsx | 194 ++++++++++++++++ client/src/hooks/useIsMobile.ts | 18 ++ client/src/i18n/translations/en.ts | 1 + client/src/pages/JourneyDetailPage.tsx | 113 +++++++-- client/src/pages/JourneyPublicPage.tsx | 19 +- 8 files changed, 713 insertions(+), 36 deletions(-) create mode 100644 client/src/components/Journey/MobileEntryCard.tsx create mode 100644 client/src/components/Journey/MobileEntryView.tsx create mode 100644 client/src/components/Journey/MobileMapTimeline.tsx create mode 100644 client/src/hooks/useIsMobile.ts diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 88b08d0b..4363b4e3 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -33,6 +33,8 @@ interface Props { dark?: boolean activeMarkerId?: string | null onMarkerClick?: (id: string, type?: string) => void + fullScreen?: boolean + paddingBottom?: number } function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] { @@ -57,15 +59,20 @@ const MARKER_W = 28 const MARKER_H = 36 function markerSvg(index: number, highlighted: boolean, dark: boolean): string { + // Highlighted: inverted colors for contrast (black on light, white on dark) const fill = dark - ? (highlighted ? '#FAFAFA' : '#FAFAFA') - : (highlighted ? '#18181B' : '#18181B') + ? (highlighted ? '#FAFAFA' : '#A1A1AA') + : (highlighted ? '#18181B' : '#52525B') const textColor = dark ? (highlighted ? '#18181B' : '#18181B') : (highlighted ? '#fff' : '#fff') - const stroke = dark ? '#3F3F46' : '#fff' + const stroke = highlighted + ? (dark ? '#fff' : '#18181B') + : (dark ? '#3F3F46' : '#fff') const shadow = highlighted - ? 'filter:drop-shadow(0 0 8px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))' + ? (dark + ? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' + : 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))') : 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))' const label = String(index + 1) const scale = highlighted ? 1.2 : 1 @@ -82,7 +89,7 @@ function markerSvg(index: number, highlighted: boolean, dark: boolean): string { const EMPTY_TRAIL: { lat: number; lng: number }[] = [] const JourneyMap = forwardRef(function JourneyMap( - { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick }, + { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom }, ref ) { const stableTrail = trail || EMPTY_TRAIL @@ -138,7 +145,9 @@ const JourneyMap = forwardRef(function JourneyMap( highlightMarker(id) const marker = markersRef.current.get(id) if (marker && mapRef.current) { - mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 }) + try { + mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 }) + } catch { /* map not yet initialized */ } } }, []) @@ -156,7 +165,7 @@ const JourneyMap = forwardRef(function JourneyMap( const map = L.map(containerRef.current, { zoomControl: false, attributionControl: true, - scrollWheelZoom: false, + scrollWheelZoom: fullScreen ? true : false, dragging: true, touchZoom: true, }) @@ -185,8 +194,8 @@ const JourneyMap = forwardRef(function JourneyMap( coords.forEach(c => allCoords.push(c)) } - // route polyline — subtle dashed connection - if (items.length > 1) { + // route polyline — only in non-fullscreen (sidebar map) mode + if (!fullScreen && items.length > 1) { const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple) L.polyline(routeCoords, { color: dark ? '#71717A' : '#A1A1AA', @@ -229,7 +238,8 @@ const JourneyMap = forwardRef(function JourneyMap( try { map.invalidateSize() if (allCoords.length > 0) { - map.fitBounds(L.latLngBounds(allCoords), { padding: [50, 50], maxZoom: 14 }) + const pb = paddingBottom || 50 + map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 14 }) } else { map.setView([30, 0], 2) } @@ -245,7 +255,7 @@ const JourneyMap = forwardRef(function JourneyMap( mapRef.current = null markersRef.current.clear() } - }, [entries, stableTrail, dark, mapTileUrl]) + }, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom]) // react to activeMarkerId prop changes — runs after map is built useEffect(() => { diff --git a/client/src/components/Journey/MobileEntryCard.tsx b/client/src/components/Journey/MobileEntryCard.tsx new file mode 100644 index 00000000..0f29f87e --- /dev/null +++ b/client/src/components/Journey/MobileEntryCard.tsx @@ -0,0 +1,154 @@ +import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react' +import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' + +const MOOD_ICONS: Record = { + amazing: Laugh, + good: Smile, + neutral: Meh, + rough: Frown, +} + +const MOOD_COLORS: Record = { + amazing: 'text-pink-500', + good: 'text-amber-500', + neutral: 'text-zinc-400', + rough: 'text-violet-500', +} + +const WEATHER_ICONS: Record = { + sunny: Sun, + partly: CloudSun, + cloudy: Cloud, + rainy: CloudRain, + stormy: CloudLightning, + cold: Snowflake, +} + +function photoUrl(p: JourneyPhoto): string { + return `/api/photos/${p.photo_id}/thumbnail` +} + +function stripMarkdown(text: string): string { + return text + .replace(/[#*_~`>\[\]()!|-]/g, '') + .replace(/\n+/g, ' ') + .trim() +} + +interface Props { + entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null } + index: number + isActive: boolean + onClick: () => void + publicPhotoUrl?: (photoId: number) => string +} + +export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) { + const hasLocation = !!(entry.location_lat && entry.location_lng) + const hasPhotos = entry.photos && entry.photos.length > 0 + const firstPhoto = hasPhotos ? entry.photos![0] : null + const MoodIcon = entry.mood ? MOOD_ICONS[entry.mood] : null + const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : '' + const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null + + const thumbSrc = firstPhoto + ? publicPhotoUrl + ? publicPhotoUrl((firstPhoto as any).photo_id ?? (firstPhoto as any).id) + : photoUrl(firstPhoto as JourneyPhoto) + : null + + const date = new Date(entry.entry_date + 'T00:00:00') + const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + + const storyPreview = entry.story ? stripMarkdown(entry.story) : '' + + return ( + + ) +} diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx new file mode 100644 index 00000000..0062d96e --- /dev/null +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -0,0 +1,218 @@ +import { useState } from 'react' +import { + X, Pencil, Trash2, MapPin, Clock, Camera, + Laugh, Smile, Meh, Frown, + Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, + ThumbsUp, ThumbsDown, ChevronDown, +} from 'lucide-react' +import JournalBody from './JournalBody' +import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' + +const MOOD_CONFIG: Record = { + amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' }, + good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, + neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' }, + rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' }, +} + +const WEATHER_CONFIG: Record = { + sunny: { icon: Sun, label: 'Sunny' }, + partly: { icon: CloudSun, label: 'Partly cloudy' }, + cloudy: { icon: Cloud, label: 'Cloudy' }, + rainy: { icon: CloudRain, label: 'Rainy' }, + stormy: { icon: CloudLightning, label: 'Stormy' }, + cold: { icon: Snowflake, label: 'Cold' }, +} + +function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string { + return `/api/photos/${p.photo_id}/${size}` +} + +interface Props { + entry: JourneyEntry + onClose: () => void + onEdit: () => void + onDelete: () => void + onPhotoClick: (photos: JourneyPhoto[], index: number) => void +} + +export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPhotoClick }: Props) { + 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 + + const date = new Date(entry.entry_date + 'T00:00:00') + const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' }) + + return ( +
+ {/* Top bar */} +
+ +
+ + +
+
+ + {/* Scrollable content */} +
+ + {/* Hero photo(s) */} + {photos.length > 0 && ( +
+ onPhotoClick(photos, 0)} + /> + {photos.length > 1 && ( +
+ + {photos.length} photos +
+ )} + {/* Photo strip for multiple photos */} + {photos.length > 1 && ( +
+ {photos.map((p, i) => ( + onPhotoClick(photos, i)} + /> + ))} +
+ )} +
+ )} + + {/* Content */} +
+ + {/* Date + time + location header */} +
+ {dateStr} + {entry.entry_time && ( + + + {entry.entry_time.slice(0, 5)} + + )} +
+ + {entry.location_name && ( +
+ + + {entry.location_name} + +
+ )} + + {/* Title */} + {entry.title && ( +

+ {entry.title} +

+ )} + + {/* Mood + Weather chips */} + {(mood || weather) && ( +
+ {mood && ( + + + {mood.label} + + )} + {weather && ( + + + {weather.label} + + )} +
+ )} + + {/* Story */} + {entry.story && ( +
+ +
+ )} + + {/* Tags */} + {entry.tags && entry.tags.length > 0 && ( +
+ {entry.tags.map((tag, i) => ( + + {tag} + + ))} +
+ )} + + {/* Pros & Cons */} + {hasProscons && ( +
+ {prosArr.length > 0 && ( +
+
+ Pros +
+
    + {prosArr.map((p, i) => ( +
  • + + {p} +
  • + ))} +
+
+ )} + {prosArr.length > 0 && consArr.length > 0 && ( +
+ )} + {consArr.length > 0 && ( +
+
+ Cons +
+
    + {consArr.map((c, i) => ( +
  • + {c} +
  • + ))} +
+
+ )} +
+ )} +
+
+
+ ) +} diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx new file mode 100644 index 00000000..a0f64df4 --- /dev/null +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -0,0 +1,194 @@ +import { useRef, useState, useEffect, useCallback } from 'react' +import { Plus } from 'lucide-react' +import JourneyMap from './JourneyMap' +import MobileEntryCard from './MobileEntryCard' +import type { JourneyMapHandle } from './JourneyMap' +import type { JourneyEntry } from '../../store/journeyStore' + +interface MapEntry { + id: string + lat: number + lng: number + title?: string | null + mood?: string | null + entry_date: string +} + +interface Props { + entries: JourneyEntry[] | any[] + mapEntries: MapEntry[] + trail?: { lat: number; lng: number }[] + dark?: boolean + readOnly?: boolean + onEntryClick: (entry: any) => void + onAddEntry?: () => void + publicPhotoUrl?: (photoId: number) => string +} + +export default function MobileMapTimeline({ + entries, + mapEntries, + trail, + dark, + readOnly, + onEntryClick, + onAddEntry, + publicPhotoUrl, +}: Props) { + const mapRef = useRef(null) + const carouselRef = useRef(null) + const [activeIndex, setActiveIndex] = useState(0) + const cardRefs = useRef>(new Map()) + + // Sync map focus when carousel scrolls (with guard for uninitialized map) + const syncMapToCarousel = useCallback((index: number) => { + const entry = entries[index] + if (!entry) return + + const mapEntry = mapEntries.find(m => String(m.id) === String(entry.id)) + if (mapEntry) { + try { mapRef.current?.focusMarker(String(mapEntry.id)) } catch {} + } else { + try { mapRef.current?.highlightMarker(null) } catch {} + } + }, [entries, mapEntries]) + + // IntersectionObserver for instant snap detection + useEffect(() => { + const el = carouselRef.current + if (!el || entries.length === 0) return + + const observer = new IntersectionObserver( + (observed) => { + for (const o of observed) { + if (o.isIntersecting) { + const idx = Number(o.target.getAttribute('data-idx')) + if (!isNaN(idx)) { + setActiveIndex(idx) + syncMapToCarousel(idx) + } + } + } + }, + { root: el, threshold: 0.6 }, + ) + + cardRefs.current.forEach(node => observer.observe(node)) + return () => observer.disconnect() + }, [entries.length, syncMapToCarousel]) + + // Scroll carousel to entry when map marker is clicked + const handleMarkerClick = useCallback((id: string) => { + const idx = entries.findIndex((e: any) => String(e.id) === id) + if (idx === -1) return + setActiveIndex(idx) + + const el = carouselRef.current + if (!el) return + const cardWidth = 272 + el.scrollTo({ left: idx * cardWidth, behavior: 'smooth' }) + }, [entries]) + + // Initial map focus — delay to let Leaflet initialize and fitBounds + useEffect(() => { + if (entries.length > 0) { + const timer = setTimeout(() => syncMapToCarousel(0), 500) + return () => clearTimeout(timer) + } + }, [entries.length]) + + const activeEntryId = entries[activeIndex] + ? String(entries[activeIndex].id) + : null + + if (entries.length === 0) { + return ( +
+ + {!readOnly && onAddEntry && ( +
+ +
+ )} +
+ ) + } + + return ( +
+ {/* Full-screen map */} + + + {/* Bottom carousel */} +
+
+ {entries.map((entry: any, i: number) => ( +
{ if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }} + style={{ scrollSnapAlign: 'center' }} + > + onEntryClick(entry)} + publicPhotoUrl={publicPhotoUrl} + /> +
+ ))} +
+
+ + {/* FAB: add entry — top right */} + {!readOnly && onAddEntry && ( +
+ +
+ )} +
+ ) +} diff --git a/client/src/hooks/useIsMobile.ts b/client/src/hooks/useIsMobile.ts new file mode 100644 index 00000000..82504f3c --- /dev/null +++ b/client/src/hooks/useIsMobile.ts @@ -0,0 +1,18 @@ +import { useState, useEffect } from 'react' + +/** Returns true when the viewport is below the lg breakpoint (1024px). */ +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState( + () => typeof window !== 'undefined' && window.innerWidth < 1024, + ) + + useEffect(() => { + const mq = window.matchMedia('(max-width: 1023px)') + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches) + setIsMobile(mq.matches) + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, []) + + return isMobile +} diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index cd70f881..e0fb72c7 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1958,6 +1958,7 @@ const en: Record = { 'journey.detail.noEntriesHint': 'Add a trip to get started with skeleton entries', 'journey.detail.noPhotos': 'No photos yet', 'journey.detail.noPhotosHint': 'Upload photos to entries or browse your Immich/Synology library', + 'journey.detail.journeyTab': 'Journey', 'journey.detail.journeyStats': 'Journey Stats', 'journey.detail.syncedTrips': 'Synced Trips', 'journey.detail.noTripsLinked': 'No trips linked yet', diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 028b0ecc..295373f6 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react' +import { createPortal } from 'react-dom' import { useParams, useNavigate } from 'react-router-dom' import { useJourneyStore } from '../store/journeyStore' import { useAuthStore } from '../store/authStore' @@ -20,6 +21,9 @@ import { Laugh, Smile, Meh, Annoyed, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff, } 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' const GRADIENTS = [ @@ -84,7 +88,9 @@ export default function JourneyDetailPage() { const fullMapRef = useRef(null) const [activeLocationId, setActiveLocationId] = useState(null) + const isMobile = useIsMobile() const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline') + 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) @@ -202,10 +208,61 @@ export default function JourneyDetailPage() { const dayGroups = groupByDate(timelineEntries) const sortedDates = [...dayGroups.keys()].sort() + const showMobileCombined = isMobile && view === 'timeline' + return (
-
+ + {/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */} + {showMobileCombined && ( + setViewingEntry(entry)} + onAddEntry={() => { + 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) + }} + /> + )} + + {/* Fullscreen entry view (mobile) — portal to body for iOS stacking */} + {viewingEntry && createPortal( + 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 })} + />, + document.body + )} + + {/* Floating tab toggle on mobile combined view */} + {showMobileCombined && ( +
+
+ + +
+
+ )} + +
{/* Back link — desktop */} @@ -298,11 +355,17 @@ export default function JourneyDetailPage() { {/* View Controls */}
- {[ - { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, - { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, - { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, - ].map(v => ( + {(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') }, + { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, + ] + ).map(v => ( ))}
- {view === 'timeline' && ( + {(!isMobile ? view === 'timeline' : view !== 'gallery') && ( )}
- {/* Timeline */} - {view === 'timeline' && ( + {/* Timeline (desktop only — mobile uses fullscreen combined view above) */} + {!isMobile && view === 'timeline' && (
{sortedDates.length === 0 && (
@@ -398,8 +461,8 @@ export default function JourneyDetailPage() { /> )} - {/* Full Map View */} - {view === 'map' &&
- {/* Entry Editor */} - {editingEntry && ( + {/* Entry Editor — portal to body to escape stacking context on iOS */} + {editingEntry && createPortal( + />, + document.body )} {/* Journey Settings */} @@ -2029,8 +2093,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa } return ( -
-
+
+
+

{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}

@@ -2187,7 +2252,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
{pros.map((p, i) => ( -
+
{cons.map((c, i) => ( -
+
{locationLat && (
- +
)}
@@ -2332,8 +2397,10 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa const active = mood === key return ( @@ -2363,7 +2430,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
-
+
)} - {/* Timeline */} - {view === 'timeline' && perms.share_timeline && ( + {/* Mobile combined map+timeline (public, read-only) */} + {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 }))} + dark={document.documentElement.classList.contains('dark')} + readOnly + onEntryClick={() => {}} + publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} + /> + )} + + {/* Timeline (desktop, or mobile without map permission) */} + {(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
{sortedDates.map(date => { const dayEntries = groupedEntries.get(date)!