mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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)
This commit is contained in:
@@ -33,6 +33,8 @@ interface Props {
|
|||||||
dark?: boolean
|
dark?: boolean
|
||||||
activeMarkerId?: string | null
|
activeMarkerId?: string | null
|
||||||
onMarkerClick?: (id: string, type?: string) => void
|
onMarkerClick?: (id: string, type?: string) => void
|
||||||
|
fullScreen?: boolean
|
||||||
|
paddingBottom?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||||
@@ -57,15 +59,20 @@ const MARKER_W = 28
|
|||||||
const MARKER_H = 36
|
const MARKER_H = 36
|
||||||
|
|
||||||
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
||||||
|
// Highlighted: inverted colors for contrast (black on light, white on dark)
|
||||||
const fill = dark
|
const fill = dark
|
||||||
? (highlighted ? '#FAFAFA' : '#FAFAFA')
|
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
||||||
: (highlighted ? '#18181B' : '#18181B')
|
: (highlighted ? '#18181B' : '#52525B')
|
||||||
const textColor = dark
|
const textColor = dark
|
||||||
? (highlighted ? '#18181B' : '#18181B')
|
? (highlighted ? '#18181B' : '#18181B')
|
||||||
: (highlighted ? '#fff' : '#fff')
|
: (highlighted ? '#fff' : '#fff')
|
||||||
const stroke = dark ? '#3F3F46' : '#fff'
|
const stroke = highlighted
|
||||||
|
? (dark ? '#fff' : '#18181B')
|
||||||
|
: (dark ? '#3F3F46' : '#fff')
|
||||||
const shadow = highlighted
|
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))'
|
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||||
const label = String(index + 1)
|
const label = String(index + 1)
|
||||||
const scale = highlighted ? 1.2 : 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 EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||||
|
|
||||||
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick },
|
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const stableTrail = trail || EMPTY_TRAIL
|
const stableTrail = trail || EMPTY_TRAIL
|
||||||
@@ -138,7 +145,9 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
highlightMarker(id)
|
highlightMarker(id)
|
||||||
const marker = markersRef.current.get(id)
|
const marker = markersRef.current.get(id)
|
||||||
if (marker && mapRef.current) {
|
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<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
const map = L.map(containerRef.current, {
|
const map = L.map(containerRef.current, {
|
||||||
zoomControl: false,
|
zoomControl: false,
|
||||||
attributionControl: true,
|
attributionControl: true,
|
||||||
scrollWheelZoom: false,
|
scrollWheelZoom: fullScreen ? true : false,
|
||||||
dragging: true,
|
dragging: true,
|
||||||
touchZoom: true,
|
touchZoom: true,
|
||||||
})
|
})
|
||||||
@@ -185,8 +194,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
coords.forEach(c => allCoords.push(c))
|
coords.forEach(c => allCoords.push(c))
|
||||||
}
|
}
|
||||||
|
|
||||||
// route polyline — subtle dashed connection
|
// route polyline — only in non-fullscreen (sidebar map) mode
|
||||||
if (items.length > 1) {
|
if (!fullScreen && items.length > 1) {
|
||||||
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
|
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
|
||||||
L.polyline(routeCoords, {
|
L.polyline(routeCoords, {
|
||||||
color: dark ? '#71717A' : '#A1A1AA',
|
color: dark ? '#71717A' : '#A1A1AA',
|
||||||
@@ -229,7 +238,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
try {
|
try {
|
||||||
map.invalidateSize()
|
map.invalidateSize()
|
||||||
if (allCoords.length > 0) {
|
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 {
|
} else {
|
||||||
map.setView([30, 0], 2)
|
map.setView([30, 0], 2)
|
||||||
}
|
}
|
||||||
@@ -245,7 +255,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
mapRef.current = null
|
mapRef.current = null
|
||||||
markersRef.current.clear()
|
markersRef.current.clear()
|
||||||
}
|
}
|
||||||
}, [entries, stableTrail, dark, mapTileUrl])
|
}, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom])
|
||||||
|
|
||||||
// react to activeMarkerId prop changes — runs after map is built
|
// react to activeMarkerId prop changes — runs after map is built
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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<string, typeof Smile> = {
|
||||||
|
amazing: Laugh,
|
||||||
|
good: Smile,
|
||||||
|
neutral: Meh,
|
||||||
|
rough: Frown,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOOD_COLORS: Record<string, string> = {
|
||||||
|
amazing: 'text-pink-500',
|
||||||
|
good: 'text-amber-500',
|
||||||
|
neutral: 'text-zinc-400',
|
||||||
|
rough: 'text-violet-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEATHER_ICONS: Record<string, typeof Sun> = {
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex-shrink-0 rounded-xl overflow-hidden text-left transition-all duration-100 ${
|
||||||
|
isActive
|
||||||
|
? 'w-[320px] sm:w-[340px] bg-white dark:bg-zinc-800 shadow-lg ring-2 ring-zinc-900/70 dark:ring-white/60'
|
||||||
|
: 'w-[240px] sm:w-[260px] bg-white/90 dark:bg-zinc-800/90 shadow-md'
|
||||||
|
} backdrop-blur-lg`}
|
||||||
|
>
|
||||||
|
<div className={`flex ${isActive ? 'h-[140px]' : 'h-[110px]'} transition-all duration-100`}>
|
||||||
|
{/* Photo thumbnail */}
|
||||||
|
{thumbSrc ? (
|
||||||
|
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 relative overflow-hidden transition-all duration-100`}>
|
||||||
|
<img
|
||||||
|
src={thumbSrc}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{hasPhotos && entry.photos!.length > 1 && (
|
||||||
|
<div className="absolute bottom-1 right-1 flex items-center gap-0.5 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] font-medium">
|
||||||
|
<Camera size={10} />
|
||||||
|
{entry.photos!.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center transition-all duration-100`}>
|
||||||
|
<MapPin size={20} className="text-zinc-300 dark:text-zinc-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 p-3 flex flex-col min-w-0">
|
||||||
|
{/* Day number + date + mood/weather */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<span className="w-5 h-5 rounded bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] font-bold flex items-center justify-center flex-shrink-0">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
||||||
|
{entry.entry_time && (
|
||||||
|
<span className="text-[11px] text-zinc-400">· {entry.entry_time.slice(0, 5)}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0">
|
||||||
|
{MoodIcon && (
|
||||||
|
<span className={`inline-flex items-center justify-center w-5 h-5 rounded-full ${
|
||||||
|
entry.mood === 'amazing' ? 'bg-pink-100 dark:bg-pink-900/30' :
|
||||||
|
entry.mood === 'good' ? 'bg-amber-100 dark:bg-amber-900/30' :
|
||||||
|
entry.mood === 'rough' ? 'bg-violet-100 dark:bg-violet-900/30' :
|
||||||
|
'bg-zinc-100 dark:bg-zinc-700'
|
||||||
|
}`}>
|
||||||
|
<MoodIcon size={11} className={moodColor} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{WeatherIcon && (
|
||||||
|
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-zinc-100 dark:bg-zinc-700">
|
||||||
|
<WeatherIcon size={11} className="text-zinc-500 dark:text-zinc-400" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h4 className="text-[13px] font-semibold text-zinc-900 dark:text-white leading-tight truncate">
|
||||||
|
{entry.title || (entry.type === 'checkin' ? 'Check-in' : entry.type === 'skeleton' ? 'Add your story…' : 'Untitled')}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Story preview (1-2 lines, only on active card) */}
|
||||||
|
{isActive && storyPreview && (
|
||||||
|
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 leading-snug mt-0.5 line-clamp-2">
|
||||||
|
{storyPreview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location badge */}
|
||||||
|
<div className="flex items-center gap-1 mt-auto">
|
||||||
|
{hasLocation ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
|
||||||
|
<MapPin size={10} className="flex-shrink-0" />
|
||||||
|
<span className="truncate">{entry.location_name || 'On the map'}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<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: 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 (
|
||||||
|
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-9 h-9 rounded-lg flex items-center justify-center text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => { onClose(); onEdit(); }}
|
||||||
|
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { onClose(); onDelete(); }}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
|
||||||
|
|
||||||
|
{/* Hero photo(s) */}
|
||||||
|
{photos.length > 0 && (
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={photoUrl(photos[0])}
|
||||||
|
alt=""
|
||||||
|
className="w-full max-h-[50vh] object-cover cursor-pointer"
|
||||||
|
onClick={() => onPhotoClick(photos, 0)}
|
||||||
|
/>
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
|
||||||
|
<Camera size={12} />
|
||||||
|
{photos.length} photos
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Photo strip for multiple photos */}
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<div className="flex gap-1 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900">
|
||||||
|
{photos.map((p, i) => (
|
||||||
|
<img
|
||||||
|
key={p.id || i}
|
||||||
|
src={photoUrl(p, 'thumbnail')}
|
||||||
|
alt=""
|
||||||
|
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
||||||
|
onClick={() => onPhotoClick(photos, i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-5 py-5 pb-32">
|
||||||
|
|
||||||
|
{/* Date + time + location header */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
<span className="text-[12px] font-medium text-zinc-500">{dateStr}</span>
|
||||||
|
{entry.entry_time && (
|
||||||
|
<span className="flex items-center gap-1 text-[12px] text-zinc-400">
|
||||||
|
<Clock size={11} />
|
||||||
|
{entry.entry_time.slice(0, 5)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entry.location_name && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||||
|
{entry.location_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
{entry.title && (
|
||||||
|
<h1 className="text-[22px] font-bold text-zinc-900 dark:text-white tracking-tight leading-tight mb-4">
|
||||||
|
{entry.title}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mood + Weather chips */}
|
||||||
|
{(mood || weather) && (
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
{mood && (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold ${mood.bg} ${mood.text}`}>
|
||||||
|
<mood.icon size={13} />
|
||||||
|
{mood.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{weather && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||||
|
<weather.icon size={13} />
|
||||||
|
{weather.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Story */}
|
||||||
|
{entry.story && (
|
||||||
|
<div className="text-[14px] leading-relaxed text-zinc-700 dark:text-zinc-300 mb-5">
|
||||||
|
<JournalBody text={entry.story} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{entry.tags && entry.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-5">
|
||||||
|
{entry.tags.map((tag, i) => (
|
||||||
|
<span key={i} className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pros & Cons */}
|
||||||
|
{hasProscons && (
|
||||||
|
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-5">
|
||||||
|
{prosArr.length > 0 && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-2">
|
||||||
|
<ThumbsUp size={12} /> Pros
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{prosArr.map((p, i) => (
|
||||||
|
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||||
|
<span className="text-emerald-500 mt-0.5">+</span> {p}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{prosArr.length > 0 && consArr.length > 0 && (
|
||||||
|
<div className="border-t border-zinc-200 dark:border-zinc-700" />
|
||||||
|
)}
|
||||||
|
{consArr.length > 0 && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-red-500 dark:text-red-400 uppercase tracking-wide mb-2">
|
||||||
|
<ThumbsDown size={12} /> Cons
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{consArr.map((c, i) => (
|
||||||
|
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||||
|
<span className="text-red-500 mt-0.5">−</span> {c}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<JourneyMapHandle>(null)
|
||||||
|
const carouselRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
|
const cardRefs = useRef<Map<number, HTMLDivElement>>(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 (
|
||||||
|
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
||||||
|
<JourneyMap
|
||||||
|
ref={mapRef}
|
||||||
|
entries={mapEntries}
|
||||||
|
checkins={[]}
|
||||||
|
trail={trail}
|
||||||
|
height={9999}
|
||||||
|
dark={dark}
|
||||||
|
onMarkerClick={handleMarkerClick}
|
||||||
|
fullScreen
|
||||||
|
/>
|
||||||
|
{!readOnly && onAddEntry && (
|
||||||
|
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
|
||||||
|
<button
|
||||||
|
onClick={onAddEntry}
|
||||||
|
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
||||||
|
{/* Full-screen map */}
|
||||||
|
<JourneyMap
|
||||||
|
ref={mapRef}
|
||||||
|
entries={mapEntries}
|
||||||
|
checkins={[]}
|
||||||
|
trail={trail}
|
||||||
|
height={9999}
|
||||||
|
dark={dark}
|
||||||
|
activeMarkerId={activeEntryId}
|
||||||
|
onMarkerClick={handleMarkerClick}
|
||||||
|
fullScreen
|
||||||
|
paddingBottom={200}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom carousel */}
|
||||||
|
<div
|
||||||
|
className="fixed bottom-20 left-0 right-0 z-40"
|
||||||
|
style={{ touchAction: 'pan-x' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
|
||||||
|
style={{
|
||||||
|
scrollSnapType: 'x mandatory',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
msOverflowStyle: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entries.map((entry: any, i: number) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
data-idx={i}
|
||||||
|
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
|
||||||
|
style={{ scrollSnapAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<MobileEntryCard
|
||||||
|
entry={entry}
|
||||||
|
index={i}
|
||||||
|
isActive={i === activeIndex}
|
||||||
|
onClick={() => onEntryClick(entry)}
|
||||||
|
publicPhotoUrl={publicPhotoUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FAB: add entry — top right */}
|
||||||
|
{!readOnly && onAddEntry && (
|
||||||
|
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
|
||||||
|
<button
|
||||||
|
onClick={onAddEntry}
|
||||||
|
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1958,6 +1958,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.detail.noEntriesHint': 'Add a trip to get started with skeleton entries',
|
'journey.detail.noEntriesHint': 'Add a trip to get started with skeleton entries',
|
||||||
'journey.detail.noPhotos': 'No photos yet',
|
'journey.detail.noPhotos': 'No photos yet',
|
||||||
'journey.detail.noPhotosHint': 'Upload photos to entries or browse your Immich/Synology library',
|
'journey.detail.noPhotosHint': 'Upload photos to entries or browse your Immich/Synology library',
|
||||||
|
'journey.detail.journeyTab': 'Journey',
|
||||||
'journey.detail.journeyStats': 'Journey Stats',
|
'journey.detail.journeyStats': 'Journey Stats',
|
||||||
'journey.detail.syncedTrips': 'Synced Trips',
|
'journey.detail.syncedTrips': 'Synced Trips',
|
||||||
'journey.detail.noTripsLinked': 'No trips linked yet',
|
'journey.detail.noTripsLinked': 'No trips linked yet',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useJourneyStore } from '../store/journeyStore'
|
import { useJourneyStore } from '../store/journeyStore'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
@@ -20,6 +21,9 @@ import {
|
|||||||
Laugh, Smile, Meh, Annoyed, Frown,
|
Laugh, Smile, Meh, Annoyed, Frown,
|
||||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
|
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
|
||||||
} from 'lucide-react'
|
} 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 type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
||||||
|
|
||||||
const GRADIENTS = [
|
const GRADIENTS = [
|
||||||
@@ -84,7 +88,9 @@ export default function JourneyDetailPage() {
|
|||||||
const fullMapRef = useRef<JourneyMapHandle>(null)
|
const fullMapRef = useRef<JourneyMapHandle>(null)
|
||||||
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
|
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
||||||
|
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
|
||||||
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
|
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(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 [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<JourneyEntry | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
|
||||||
@@ -202,10 +208,61 @@ export default function JourneyDetailPage() {
|
|||||||
const dayGroups = groupByDate(timelineEntries)
|
const dayGroups = groupByDate(timelineEntries)
|
||||||
const sortedDates = [...dayGroups.keys()].sort()
|
const sortedDates = [...dayGroups.keys()].sort()
|
||||||
|
|
||||||
|
const showMobileCombined = isMobile && view === 'timeline'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div style={{ paddingTop: 'var(--nav-h, 0px)' }}>
|
|
||||||
|
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */}
|
||||||
|
{showMobileCombined && (
|
||||||
|
<MobileMapTimeline
|
||||||
|
entries={timelineEntries}
|
||||||
|
mapEntries={sidebarMapItems}
|
||||||
|
dark={document.documentElement.classList.contains('dark')}
|
||||||
|
onEntryClick={(entry) => 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(
|
||||||
|
<MobileEntryView
|
||||||
|
entry={viewingEntry}
|
||||||
|
onClose={() => 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 && (
|
||||||
|
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] left-4 z-30">
|
||||||
|
<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">
|
||||||
|
<button
|
||||||
|
onClick={() => setView('timeline')}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium bg-zinc-900 dark:bg-white text-white dark:text-zinc-900"
|
||||||
|
>
|
||||||
|
<MapPin size={13} />
|
||||||
|
{t('journey.detail.journeyTab') || 'Journey'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('gallery')}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<Grid size={13} />
|
||||||
|
{t('journey.share.gallery')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}>
|
||||||
<div className="max-w-[1440px] mx-auto px-0 md:px-8 pt-0 md:py-6">
|
<div className="max-w-[1440px] mx-auto px-0 md:px-8 pt-0 md:py-6">
|
||||||
|
|
||||||
{/* Back link — desktop */}
|
{/* Back link — desktop */}
|
||||||
@@ -298,11 +355,17 @@ export default function JourneyDetailPage() {
|
|||||||
{/* View Controls */}
|
{/* View Controls */}
|
||||||
<div className="flex items-center justify-between mt-5 mb-5">
|
<div className="flex items-center justify-between mt-5 mb-5">
|
||||||
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden">
|
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden">
|
||||||
{[
|
{(isMobile
|
||||||
{ id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
? [
|
||||||
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
{ id: 'timeline' as const, icon: MapPin, label: t('journey.detail.journeyTab') || 'Journey' },
|
||||||
{ id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||||
].map(v => (
|
]
|
||||||
|
: [
|
||||||
|
{ 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 => (
|
||||||
<button
|
<button
|
||||||
key={v.id}
|
key={v.id}
|
||||||
onClick={() => setView(v.id)}
|
onClick={() => setView(v.id)}
|
||||||
@@ -317,21 +380,21 @@ export default function JourneyDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{view === 'timeline' && (
|
{(!isMobile ? view === 'timeline' : view !== 'gallery') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const today = new Date().toISOString().split('T')[0]
|
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)
|
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)
|
||||||
}}
|
}}
|
||||||
className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center hover:bg-zinc-800 dark:hover:bg-zinc-100"
|
className={`w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center hover:bg-zinc-800 dark:hover:bg-zinc-100 ${isMobile && view === 'timeline' ? 'hidden' : ''}`}
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
|
||||||
{view === 'timeline' && (
|
{!isMobile && view === 'timeline' && (
|
||||||
<div className="flex flex-col gap-6 pb-24 md:pb-6">
|
<div className="flex flex-col gap-6 pb-24 md:pb-6">
|
||||||
{sortedDates.length === 0 && (
|
{sortedDates.length === 0 && (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
@@ -398,8 +461,8 @@ export default function JourneyDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Full Map View */}
|
{/* Full Map View (desktop only — mobile uses combined view) */}
|
||||||
{view === 'map' && <div className="pb-24 md:pb-6"><MapView
|
{!isMobile && view === 'map' && <div className="pb-24 md:pb-6"><MapView
|
||||||
entries={current.entries}
|
entries={current.entries}
|
||||||
mapEntries={mapEntries}
|
mapEntries={mapEntries}
|
||||||
sortedDates={sortedDates}
|
sortedDates={sortedDates}
|
||||||
@@ -517,8 +580,8 @@ export default function JourneyDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Entry Editor */}
|
{/* Entry Editor — portal to body to escape stacking context on iOS */}
|
||||||
{editingEntry && (
|
{editingEntry && createPortal(
|
||||||
<EntryEditor
|
<EntryEditor
|
||||||
entry={editingEntry}
|
entry={editingEntry}
|
||||||
journeyId={current.id}
|
journeyId={current.id}
|
||||||
@@ -542,7 +605,8 @@ export default function JourneyDetailPage() {
|
|||||||
setEditingEntry(null)
|
setEditingEntry(null)
|
||||||
loadJourney(Number(id))
|
loadJourney(Number(id))
|
||||||
}}
|
}}
|
||||||
/>
|
/>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Journey Settings */}
|
{/* Journey Settings */}
|
||||||
@@ -2029,8 +2093,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
<div className="fixed inset-0 z-[200] flex items-end sm:items-center sm:justify-center sm:p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
|
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]">
|
||||||
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
|
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
|
||||||
@@ -2187,7 +2252,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{pros.map((p, i) => (
|
{pros.map((p, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800/30 rounded-[10px]">
|
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0" />
|
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0" />
|
||||||
<input
|
<input
|
||||||
value={p}
|
value={p}
|
||||||
@@ -2221,7 +2286,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{cons.map((c, i) => (
|
{cons.map((c, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800/30 rounded-[10px]">
|
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0" />
|
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0" />
|
||||||
<input
|
<input
|
||||||
value={c}
|
value={c}
|
||||||
@@ -2285,7 +2350,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
/>
|
/>
|
||||||
{locationLat && (
|
{locationLat && (
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
<MapPin size={13} className="text-emerald-500" />
|
<MapPin size={13} className="text-zinc-500 dark:text-zinc-400" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2332,8 +2397,10 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
const active = mood === key
|
const active = mood === key
|
||||||
return (
|
return (
|
||||||
<button key={key} onClick={() => setMood(active ? '' : key)}
|
<button key={key} onClick={() => setMood(active ? '' : key)}
|
||||||
className="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all"
|
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all ${
|
||||||
style={{ background: active ? config.bg : 'transparent', color: active ? config.text : '#71717A', borderColor: active ? config.text + '30' : '#E4E4E7' }}>
|
active ? '' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500'
|
||||||
|
}`}
|
||||||
|
style={active ? { background: config.bg, color: config.text, borderColor: config.text + '30' } : undefined}>
|
||||||
<Icon size={12} />
|
<Icon size={12} />
|
||||||
{t(config.label)}
|
{t(config.label)}
|
||||||
</button>
|
</button>
|
||||||
@@ -2363,7 +2430,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50" style={{ paddingBottom: 'max(16px, env(safe-area-inset-bottom, 16px))' }}>
|
||||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||||
<button onClick={handleSave} disabled={saving} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50">
|
<button onClick={handleSave} disabled={saving} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50">
|
||||||
{saving ? t('common.saving') : t('common.save')}
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
|
|||||||
import JourneyMap from '../components/Journey/JourneyMap'
|
import JourneyMap from '../components/Journey/JourneyMap'
|
||||||
import JournalBody from '../components/Journey/JournalBody'
|
import JournalBody from '../components/Journey/JournalBody'
|
||||||
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
||||||
|
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile'
|
||||||
|
|
||||||
interface PublicEntry {
|
interface PublicEntry {
|
||||||
id: number
|
id: number
|
||||||
@@ -62,6 +64,7 @@ export default function JourneyPublicPage() {
|
|||||||
const [data, setData] = useState<any>(null)
|
const [data, setData] = useState<any>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
||||||
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
|
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -202,8 +205,20 @@ export default function JourneyPublicPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Mobile combined map+timeline (public, read-only) */}
|
||||||
{view === 'timeline' && perms.share_timeline && (
|
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
||||||
|
<MobileMapTimeline
|
||||||
|
entries={entries}
|
||||||
|
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`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline (desktop, or mobile without map permission) */}
|
||||||
|
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{sortedDates.map(date => {
|
{sortedDates.map(date => {
|
||||||
const dayEntries = groupedEntries.get(date)!
|
const dayEntries = groupedEntries.get(date)!
|
||||||
|
|||||||
Reference in New Issue
Block a user