From 7d5dadc44195cef4f050860fa10d7f587d3fa1f1 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 21:55:45 +0200 Subject: [PATCH] feat(journey/public): match desktop timeline view to in-app experience --- client/src/pages/JourneyPublicPage.tsx | 548 +++++++++++++++++-------- 1 file changed, 370 insertions(+), 178 deletions(-) diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index 816ac6ec..f98af73e 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -3,12 +3,19 @@ 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 { + List, Grid, MapPin, Camera, BookOpen, Image, Clock, + Laugh, Smile, Meh, Frown, + Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, + ThumbsUp, ThumbsDown, +} from 'lucide-react' import JourneyMap from '../components/Journey/JourneyMap' import JournalBody from '../components/Journey/JournalBody' import PhotoLightbox from '../components/Journey/PhotoLightbox' import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import { useIsMobile } from '../hooks/useIsMobile' +import { formatLocationName } from '../utils/formatters' +import { DAY_COLORS } from '../components/Journey/dayColors' interface PublicEntry { id: number @@ -36,6 +43,22 @@ interface PublicPhoto { caption?: string | null } +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: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string { return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}` } @@ -84,10 +107,6 @@ export default function JourneyPublicPage() { const journey = data?.journey || {} const stats = data?.stats || {} - // `[Trip Photos]` and `Gallery` are synthetic photo-only containers - // produced by the trip→journey sync. They have no story and no - // location, and the owner view strips them from the timeline the - // same way (JourneyDetailPage.tsx). Gallery keeps their photos. const timelineEntries = useMemo( () => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'), [entries], @@ -100,12 +119,42 @@ 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 + 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 dayLabel = (counters.get(e.entry_date) ?? 0) + 1 + counters.set(e.entry_date, dayLabel) + return { + 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, + dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length], + dayLabel, + } + }) + }, [mapEntries]) + + // Two-column desktop layout: timeline feed left + sticky map right + const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map + // 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]) + // When switching to desktop two-column, 'map' standalone tab no longer exists + useEffect(() => { + if (desktopTwoColumn && view === 'map') setView('timeline') + }, [desktopTwoColumn]) + if (loading) { return (
@@ -125,21 +174,252 @@ export default function JourneyPublicPage() { ) } + // In desktop two-column mode the map is always visible — exclude the standalone 'map' tab 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') }, + !desktopTwoColumn && 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 }[] + // Shared timeline renderer used in both layout modes + const renderTimeline = () => ( +
+ {sortedDates.length === 0 && ( +
+
+ +
+

No entries yet

+
+ )} + {sortedDates.map((date, dayIdx) => { + const dayEntries = groupedEntries.get(date)! + const fd = formatDate(date, locale) + const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length] + return ( +
+ {/* Day header */} +
+
+ {dayIdx + 1} +
+
+
{fd.weekday}
+
{fd.month} {fd.day}
+
+
+ + {/* Entries */} +
+ {dayEntries.map(entry => { + 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 lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })) + + return ( +
+ + {/* Photo area */} + {photos.length === 1 && ( +
setLightbox({ photos: lightboxPhotos, index: 0 })}> + +
+ {entry.location_name && ( +
+ + + {formatLocationName(entry.location_name)} + +
+ )} + {entry.title && ( +
+

{entry.title}

+
+ )} +
+ )} + + {photos.length === 2 && ( +
+ {photos.slice(0, 2).map((p, i) => ( + setLightbox({ photos: lightboxPhotos, index: i })} + /> + ))} +
+ )} + + {photos.length >= 3 && ( +
+
setLightbox({ photos: lightboxPhotos, index: 0 })}> + +
+
+
setLightbox({ photos: lightboxPhotos, index: 1 })}> + +
+
setLightbox({ photos: lightboxPhotos, index: 2 })}> + + {photos.length > 3 && ( +
+ + +{photos.length - 3} + +
+ )} +
+
+
+ )} + + {/* Content */} +
+ {/* Title (only when no single photo — photo has it in overlay) */} + {photos.length !== 1 && entry.title && ( +

{entry.title}

+ )} + + {/* Location + time badges */} + {(entry.location_name || entry.entry_time) && photos.length !== 1 && ( +
+ {entry.location_name && ( + + + {formatLocationName(entry.location_name)} + + )} + {entry.entry_time && ( + + + {entry.entry_time.slice(0, 5)} + + )} +
+ )} + {entry.entry_time && photos.length === 1 && ( +
+ + {entry.entry_time.slice(0, 5)} +
+ )} + + {/* Story */} + {entry.story && ( +
+ +
+ )} + + {/* Pros & Cons */} + {hasProscons && ( +
+ {prosArr.length > 0 && ( +
+
+ Pros +
+ {prosArr.map((p, i) => ( +
+ {p} +
+ ))} +
+ )} + {consArr.length > 0 && ( +
+
+ Cons +
+ {consArr.map((c, i) => ( +
+ {c} +
+ ))} +
+ )} +
+ )} + + {/* Mood + weather */} + {(mood || weather) && ( +
+ {mood && ( + + {mood.label} + + )} + {weather && ( + + {weather.label} + + )} +
+ )} +
+
+ ) + })} +
+
+ ) + })} +
+ ) + + // Shared gallery renderer + const renderGallery = () => ( +
+ {allPhotos.map(({ photo }, idx) => ( +
setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })} + > + +
+ ))} +
+ ) + + // Shared view tab bar + const renderTabs = (views: typeof availableViews) => views.length > 1 && ( +
+ {views.map(v => ( + + ))} +
+ ) + return (
{/* Hero */}
- {/* Cover image background */} {journey.cover_image && (
)} - {/* Decorative circles */}
@@ -194,183 +474,95 @@ export default function JourneyPublicPage() {
{/* Content */} -
- - {/* View tabs */} - {availableViews.length > 1 && ( -
- {availableViews.map(v => ( - - ))} + {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()}
- )} - {/* Floating view toggle — visible above the fullscreen map on mobile */} - {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && ( -
-
- {availableViews.map(v => ( - - ))} + {/* Right: sticky map */} +
- )} + +
+ ) : ( + // ── Single-column layout (mobile + desktop-without-map) ─────────────── +
- {/* 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`} - carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)" - /> - )} - - {/* Timeline (desktop, or mobile without map permission) */} - {(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && ( -
- {sortedDates.map(date => { - const dayEntries = groupedEntries.get(date)! - const fd = formatDate(date, locale) - 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 })} - > - + {/* Floating view toggle — visible above the fullscreen map on mobile */} + {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && ( +
+
+ {availableViews.map(v => ( + + ))}
- ))} -
- )} +
+ )} - {/* 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} + {renderTabs(availableViews)} + + {/* 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`} + carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)" /> -
- )} -
+ )} + + {/* Timeline (desktop, or mobile without map permission) */} + {(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && renderTimeline()} + + {/* Gallery */} + {view === 'gallery' && perms.share_gallery && renderGallery()} + + {/* Map (standalone tab — only in single-column mode) */} + {view === 'map' && perms.share_map && ( +
+ +
+ )} +
+ )} {/* Powered by */}