mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14: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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user