mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(journey/public): match desktop timeline view to in-app experience
This commit is contained in:
@@ -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<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||
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<string, { icon: typeof Sun; label: string }> = {
|
||||
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<string, number>()
|
||||
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 (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
|
||||
@@ -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 = () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
{sortedDates.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
|
||||
<BookOpen size={24} className="text-zinc-400" />
|
||||
</div>
|
||||
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">No entries yet</p>
|
||||
</div>
|
||||
)}
|
||||
{sortedDates.map((date, dayIdx) => {
|
||||
const dayEntries = groupedEntries.get(date)!
|
||||
const fd = formatDate(date, locale)
|
||||
const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length]
|
||||
return (
|
||||
<div key={date}>
|
||||
{/* Day header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-[14px] font-bold text-white flex-shrink-0"
|
||||
style={{ background: dayColor }}
|
||||
>
|
||||
{dayIdx + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
|
||||
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entries */}
|
||||
<div className="flex flex-col gap-4 pl-[52px]">
|
||||
{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 (
|
||||
<div key={entry.id} className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
|
||||
|
||||
{/* Photo area */}
|
||||
{photos.length === 1 && (
|
||||
<div className="relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
|
||||
<img src={photoUrl(photos[0], token!)} className="w-full h-64 object-cover" alt="" />
|
||||
<div className="absolute inset-x-0 bottom-0 pointer-events-none" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 60%, transparent 100%)', height: '65%' }} />
|
||||
{entry.location_name && (
|
||||
<div className="absolute top-3 left-4">
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white">
|
||||
<MapPin size={10} className="flex-shrink-0" />
|
||||
<span className="truncate max-w-[200px]">{formatLocationName(entry.location_name)}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.title && (
|
||||
<div className="absolute bottom-4 left-5 right-5 pointer-events-none">
|
||||
<h3 className="text-[18px] font-bold text-white drop-shadow-sm leading-tight">{entry.title}</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{photos.length === 2 && (
|
||||
<div className="grid grid-cols-2 gap-0.5 overflow-hidden">
|
||||
{photos.slice(0, 2).map((p, i) => (
|
||||
<img
|
||||
key={p.id}
|
||||
src={photoUrl(p, token!, 'thumbnail')}
|
||||
alt=""
|
||||
className="w-full h-52 object-cover cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{photos.length >= 3 && (
|
||||
<div className="overflow-hidden flex" style={{ height: 280, gap: 2 }}>
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
|
||||
<img src={photoUrl(photos[0], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col" style={{ gap: 2 }}>
|
||||
<div className="flex-1 min-h-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}>
|
||||
<img src={photoUrl(photos[1], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })}>
|
||||
<img src={photoUrl(photos[2], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
|
||||
{photos.length > 3 && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<span className="text-white text-[13px] font-semibold flex items-center gap-1">
|
||||
<Image size={13} /> +{photos.length - 3}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 pt-4 pb-5">
|
||||
{/* Title (only when no single photo — photo has it in overlay) */}
|
||||
{photos.length !== 1 && entry.title && (
|
||||
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
|
||||
)}
|
||||
|
||||
{/* Location + time badges */}
|
||||
{(entry.location_name || entry.entry_time) && photos.length !== 1 && (
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
{entry.location_name && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-500">
|
||||
<MapPin size={11} className="flex-shrink-0" />
|
||||
{formatLocationName(entry.location_name)}
|
||||
</span>
|
||||
)}
|
||||
{entry.entry_time && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-400">
|
||||
<Clock size={11} />
|
||||
{entry.entry_time.slice(0, 5)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{entry.entry_time && photos.length === 1 && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-zinc-400 mb-2">
|
||||
<Clock size={11} />
|
||||
{entry.entry_time.slice(0, 5)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Story */}
|
||||
{entry.story && (
|
||||
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
||||
<JournalBody text={entry.story} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pros & Cons */}
|
||||
{hasProscons && (
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
{prosArr.length > 0 && (
|
||||
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">
|
||||
<ThumbsUp size={10} /> Pros
|
||||
</div>
|
||||
{prosArr.map((p, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{consArr.length > 0 && (
|
||||
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">
|
||||
<ThumbsDown size={10} /> Cons
|
||||
</div>
|
||||
{consArr.map((c, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mood + weather */}
|
||||
{(mood || weather) && (
|
||||
<div className="flex items-center gap-1.5 pt-3 mt-3 border-t border-zinc-100 dark:border-zinc-800">
|
||||
{mood && (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium ${mood.bg} ${mood.text}`}>
|
||||
<mood.icon size={11} /> {mood.label}
|
||||
</span>
|
||||
)}
|
||||
{weather && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||
<weather.icon size={11} /> {weather.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Shared gallery renderer
|
||||
const renderGallery = () => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
|
||||
{allPhotos.map(({ photo }, idx) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
|
||||
>
|
||||
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Shared view tab bar
|
||||
const renderTabs = (views: typeof availableViews) => views.length > 1 && (
|
||||
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
|
||||
{views.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === v.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<v.icon size={13} />
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
{/* Hero */}
|
||||
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
|
||||
{/* Cover image background */}
|
||||
{journey.cover_image && (
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
||||
)}
|
||||
{/* Decorative circles */}
|
||||
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
|
||||
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
|
||||
|
||||
@@ -194,183 +474,95 @@ export default function JourneyPublicPage() {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
|
||||
|
||||
{/* View tabs */}
|
||||
{availableViews.length > 1 && (
|
||||
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
|
||||
{availableViews.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === v.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<v.icon size={13} />
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
{desktopTwoColumn ? (
|
||||
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
|
||||
<div className="flex" style={{ alignItems: 'flex-start' }}>
|
||||
{/* Left: feed */}
|
||||
<div className="flex-1 min-w-0 px-8 py-6" style={{ maxWidth: 780 }}>
|
||||
{renderTabs(availableViews)}
|
||||
{view === 'timeline' && perms.share_timeline && renderTimeline()}
|
||||
{view === 'gallery' && perms.share_gallery && renderGallery()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating view toggle — visible above the fullscreen map on mobile */}
|
||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && (
|
||||
<div className="fixed left-0 right-0 z-50 flex justify-center px-4" style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}>
|
||||
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
||||
{availableViews.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === v.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<v.icon size={13} />
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Right: sticky map */}
|
||||
<aside
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
width: '44%', minWidth: 380, maxWidth: 680,
|
||||
position: 'sticky', top: 0, height: '100dvh',
|
||||
padding: '16px 16px 16px 0',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className="h-full rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<JourneyMap
|
||||
checkins={[]}
|
||||
entries={sidebarMapItems as any}
|
||||
height={9999}
|
||||
fullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
) : (
|
||||
// ── Single-column layout (mobile + desktop-without-map) ───────────────
|
||||
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
|
||||
|
||||
{/* Mobile combined map+timeline (public, read-only) */}
|
||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
||||
<MobileMapTimeline
|
||||
entries={timelineEntries}
|
||||
mapEntries={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 }))}
|
||||
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 && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{sortedDates.map(date => {
|
||||
const dayEntries = groupedEntries.get(date)!
|
||||
const fd = formatDate(date, locale)
|
||||
return (
|
||||
<div key={date}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[14px] font-bold">{fd.day}</div>
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
|
||||
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 pl-[52px]">
|
||||
{dayEntries.map(entry => (
|
||||
<div key={entry.id} className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
|
||||
{entry.photos.length > 0 && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={photoUrl(entry.photos[0], token!)}
|
||||
className="w-full h-52 object-cover cursor-pointer"
|
||||
alt=""
|
||||
onClick={() => setLightbox({ photos: entry.photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: 0 })}
|
||||
/>
|
||||
{entry.photos.length > 1 && (
|
||||
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
|
||||
<Image size={10} /> +{entry.photos.length - 1}
|
||||
</div>
|
||||
)}
|
||||
{entry.title && (
|
||||
<div className="absolute inset-x-0 bottom-0 p-4" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)' }}>
|
||||
<h3 className="text-[18px] font-bold text-white drop-shadow-sm">{entry.title}</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-5 py-4">
|
||||
{!entry.photos.length && entry.title && (
|
||||
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white mb-1">{entry.title}</h3>
|
||||
)}
|
||||
{entry.location_name && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500 mb-2">
|
||||
<MapPin size={11} /> {entry.location_name}
|
||||
</div>
|
||||
)}
|
||||
{entry.story && (
|
||||
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
||||
<JournalBody text={entry.story} />
|
||||
</div>
|
||||
)}
|
||||
{entry.pros_cons && ((entry.pros_cons.pros?.length ?? 0) > 0 || (entry.pros_cons.cons?.length ?? 0) > 0) && (
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
{(entry.pros_cons.pros?.length ?? 0) > 0 && (
|
||||
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">{t('journey.editor.pros')}</div>
|
||||
{entry.pros_cons.pros!.map((p, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(entry.pros_cons.cons?.length ?? 0) > 0 && (
|
||||
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">{t('journey.editor.cons')}</div>
|
||||
{entry.pros_cons.cons!.map((c, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{view === 'gallery' && perms.share_gallery && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
|
||||
{allPhotos.map(({ photo }, idx) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: idx })}
|
||||
>
|
||||
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
|
||||
{/* Floating view toggle — visible above the fullscreen map on mobile */}
|
||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && (
|
||||
<div className="fixed left-0 right-0 z-50 flex justify-center px-4" style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}>
|
||||
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
||||
{availableViews.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === v.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<v.icon size={13} />
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map */}
|
||||
{view === 'map' && perms.share_map && (
|
||||
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
||||
<JourneyMap
|
||||
checkins={[]}
|
||||
entries={mapEntries.map(e => ({
|
||||
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 && (
|
||||
<MobileMapTimeline
|
||||
entries={timelineEntries}
|
||||
mapEntries={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 }))}
|
||||
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)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
||||
<JourneyMap
|
||||
checkins={[]}
|
||||
entries={sidebarMapItems as any}
|
||||
height={500}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Powered by */}
|
||||
<div className="flex flex-col items-center py-8 gap-2">
|
||||
|
||||
Reference in New Issue
Block a user