Merge pull request #809 from mauriceboe/fix/789-800-journey-mobile-fixes

fix(journey): resolve issues #789–801 — mobile layout, day colors, location formatting, date picker, public share UX
This commit is contained in:
Julien G.
2026-04-21 22:56:54 +02:00
committed by GitHub
28 changed files with 652 additions and 284 deletions
+16 -23
View File
@@ -9,6 +9,8 @@ export interface MapMarkerItem {
label: string label: string
mood?: string | null mood?: string | null
time: string time: string
dayColor: string
dayLabel: number
} }
export interface JourneyMapHandle { export interface JourneyMapHandle {
@@ -24,6 +26,8 @@ interface MapEntry {
title?: string | null title?: string | null
mood?: string | null mood?: string | null
entry_date: string entry_date: string
dayColor?: string
dayLabel?: number
} }
interface Props { interface Props {
@@ -49,6 +53,8 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
label: e.title || 'Entry', label: e.title || 'Entry',
mood: e.mood, mood: e.mood,
time: e.entry_date, time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
}) })
} }
} }
@@ -59,30 +65,19 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const MARKER_W = 28 const MARKER_W = 28
const MARKER_H = 36 const MARKER_H = 36
function markerSvg(index: number, highlighted: boolean, dark: boolean): string { function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
// Highlighted: inverted colors for contrast (black on light, white on dark) const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
const fill = dark
? (highlighted ? '#FAFAFA' : '#A1A1AA')
: (highlighted ? '#18181B' : '#52525B')
const textColor = dark
? (highlighted ? '#18181B' : '#18181B')
: (highlighted ? '#fff' : '#fff')
const stroke = highlighted
? (dark ? '#fff' : '#18181B')
: (dark ? '#3F3F46' : '#fff')
const shadow = highlighted const shadow = highlighted
? (dark ? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
? '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(dayLabel)
const scale = highlighted ? 1.2 : 1 const scale = highlighted ? 1.2 : 1
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center"> return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/> <path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/> <circle cx="14" cy="13" r="8" fill="${dayColor}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text> <text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
</svg> </svg>
</div>` </div>`
} }
@@ -115,12 +110,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const marker = markersRef.current.get(prev) const marker = markersRef.current.get(prev)
const item = itemsRef.current.find(i => i.id === prev) const item = itemsRef.current.find(i => i.id === prev)
if (marker && item) { if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({ marker.setIcon(L.divIcon({
className: '', className: '',
iconSize: [MARKER_W, MARKER_H], iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(idx, false, isDark), html: markerSvg(item.dayColor, item.dayLabel, false),
})) }))
marker.setZIndexOffset(0) marker.setZIndexOffset(0)
} }
@@ -130,12 +124,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const marker = markersRef.current.get(id) const marker = markersRef.current.get(id)
const item = itemsRef.current.find(i => i.id === id) const item = itemsRef.current.find(i => i.id === id)
if (marker && item) { if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({ marker.setIcon(L.divIcon({
className: '', className: '',
iconSize: [MARKER_W, MARKER_H], iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(idx, true, isDark), html: markerSvg(item.dayColor, item.dayLabel, true),
})) }))
marker.setZIndexOffset(1000) marker.setZIndexOffset(1000)
} }
@@ -226,7 +219,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
className: '', className: '',
iconSize: [MARKER_W, MARKER_H], iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(i, false, !!dark), html: markerSvg(item.dayColor, item.dayLabel, false),
}) })
const marker = L.marker(pos, { icon }).addTo(map) const marker = L.marker(pos, { icon }).addTo(map)
@@ -14,6 +14,8 @@ interface MapEntry {
location_name?: string | null location_name?: string | null
mood?: string | null mood?: string | null
entry_date: string entry_date: string
dayColor?: string
dayLabel?: number
} }
interface Props { interface Props {
+16 -17
View File
@@ -18,6 +18,8 @@ interface MapEntry {
location_name?: string | null location_name?: string | null
mood?: string | null mood?: string | null
entry_date: string entry_date: string
dayColor?: string
dayLabel?: number
} }
interface Props { interface Props {
@@ -39,6 +41,8 @@ interface Item {
label: string label: string
locationName: string locationName: string
time: string time: string
dayColor: string
dayLabel: number
} }
const MARKER_W = 28 const MARKER_W = 28
@@ -55,6 +59,8 @@ function buildItems(entries: MapEntry[]): Item[] {
label: e.title || '', label: e.title || '',
locationName: e.location_name || '', locationName: e.location_name || '',
time: e.entry_date, time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
}) })
} }
} }
@@ -157,21 +163,15 @@ function ensureJourneyPopupStyle() {
document.head.appendChild(s) document.head.appendChild(s)
} }
function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement { function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
const fill = dark const fill = dayColor
? (highlighted ? '#FAFAFA' : '#A1A1AA') const textColor = '#fff'
: (highlighted ? '#18181B' : '#52525B') const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff'
const stroke = highlighted
? (dark ? '#fff' : '#18181B')
: (dark ? '#3F3F46' : '#fff')
const shadow = highlighted const shadow = highlighted
? (dark ? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
? 'drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const scale = highlighted ? 1.2 : 1 const scale = highlighted ? 1.2 : 1
const label = String(index + 1) const label = String(dayLabel)
// Outer wrap holds the element mapbox positions via `transform: translate(...)`. // Outer wrap holds the element mapbox positions via `transform: translate(...)`.
// Anything animated (scale, filter) has to live on an inner child — otherwise // Anything animated (scale, filter) has to live on an inner child — otherwise
@@ -183,7 +183,7 @@ function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDiv
inner.className = 'trek-journey-marker-inner' inner.className = 'trek-journey-marker-inner'
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};` inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg"> inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/> <path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/> <circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text> <text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
</svg>` </svg>`
@@ -273,13 +273,12 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
const item = itemsRef.current.find(i => i.id === id) const item = itemsRef.current.find(i => i.id === id)
const marker = markersRef.current.get(id) const marker = markersRef.current.get(id)
if (!item || !marker) return if (!item || !marker) return
const idx = itemsRef.current.indexOf(item)
const el = marker.getElement() const el = marker.getElement()
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
if (!currentInner) return if (!currentInner) return
// Only swap the inner element's styles/HTML. Touching `el.style.cssText` // Only swap the inner element's styles/HTML. Touching `el.style.cssText`
// would wipe mapbox's positional transform and make the marker flicker. // would wipe mapbox's positional transform and make the marker flicker.
const next = markerHtml(idx, highlighted, !!darkRef.current) const next = markerHtml(item.dayColor, item.dayLabel, highlighted)
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
currentInner.style.cssText = nextInner.style.cssText currentInner.style.cssText = nextInner.style.cssText
currentInner.innerHTML = nextInner.innerHTML currentInner.innerHTML = nextInner.innerHTML
@@ -382,8 +381,8 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
} }
// markers // markers
items.forEach((item, i) => { items.forEach((item) => {
const el = markerHtml(i, false, !!darkRef.current) const el = markerHtml(item.dayColor, item.dayLabel, false)
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' }) const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat]) .setLngLat([item.lng, item.lat])
.addTo(map) .addTo(map)
@@ -1,4 +1,5 @@
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react' import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_ICONS: Record<string, typeof Smile> = { const MOOD_ICONS: Record<string, typeof Smile> = {
@@ -37,13 +38,14 @@ function stripMarkdown(text: string): string {
interface Props { 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 } 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 dayLabel: number
dayColor: string
isActive: boolean isActive: boolean
onClick: () => void onClick: () => void
publicPhotoUrl?: (photoId: number) => string publicPhotoUrl?: (photoId: number) => string
} }
export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) { export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
const hasLocation = !!(entry.location_lat && entry.location_lng) const hasLocation = !!(entry.location_lat && entry.location_lng)
const hasPhotos = entry.photos && entry.photos.length > 0 const hasPhotos = entry.photos && entry.photos.length > 0
const firstPhoto = hasPhotos ? entry.photos![0] : null const firstPhoto = hasPhotos ? entry.photos![0] : null
@@ -98,8 +100,8 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
<div className="flex-1 p-3 flex flex-col min-w-0"> <div className="flex-1 p-3 flex flex-col min-w-0">
{/* Day number + date + mood/weather */} {/* Day number + date + mood/weather */}
<div className="flex items-center gap-1.5 mb-1"> <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"> <span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
{index + 1} {dayLabel}
</span> </span>
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span> <span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
{entry.entry_time && ( {entry.entry_time && (
@@ -141,7 +143,7 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
{hasLocation ? ( {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"> <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" /> <MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{entry.location_name || 'On the map'}</span> <span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
</span> </span>
) : ( ) : (
<span className="text-[10px] text-zinc-400 italic">No location</span> <span className="text-[10px] text-zinc-400 italic">No location</span>
@@ -6,6 +6,7 @@ import {
ThumbsUp, ThumbsDown, ChevronDown, ThumbsUp, ThumbsDown, ChevronDown,
} from 'lucide-react' } from 'lucide-react'
import JournalBody from './JournalBody' import JournalBody from './JournalBody'
import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = { const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
@@ -130,7 +131,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
<div className="mb-3"> <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"> <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" /> <MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
{entry.location_name} {formatLocationName(entry.location_name)}
</span> </span>
</div> </div>
)} )}
@@ -1,9 +1,10 @@
import { useRef, useState, useEffect, useCallback } from 'react' import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap' import JourneyMap from './JourneyMap'
import MobileEntryCard from './MobileEntryCard' import MobileEntryCard from './MobileEntryCard'
import type { JourneyMapHandle } from './JourneyMap' import type { JourneyMapHandle } from './JourneyMap'
import type { JourneyEntry } from '../../store/journeyStore' import type { JourneyEntry } from '../../store/journeyStore'
import { DAY_COLORS } from './dayColors'
interface MapEntry { interface MapEntry {
id: string id: string
@@ -23,6 +24,7 @@ interface Props {
onEntryClick: (entry: any) => void onEntryClick: (entry: any) => void
onAddEntry?: () => void onAddEntry?: () => void
publicPhotoUrl?: (photoId: number) => string publicPhotoUrl?: (photoId: number) => string
carouselBottom?: string
} }
export default function MobileMapTimeline({ export default function MobileMapTimeline({
@@ -34,14 +36,23 @@ export default function MobileMapTimeline({
onEntryClick, onEntryClick,
onAddEntry, onAddEntry,
publicPhotoUrl, publicPhotoUrl,
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
}: Props) { }: Props) {
const mapRef = useRef<JourneyMapHandle>(null) const mapRef = useRef<JourneyMapHandle>(null)
const carouselRef = useRef<HTMLDivElement>(null) const carouselRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0) const [activeIndex, setActiveIndex] = useState(0)
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
const activeIndexRef = useRef(activeIndex)
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
const entryDayMeta = useMemo(() => {
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())]
const counters = new Map<string, number>()
return entries.map((e: any) => {
const dayIdx = uniqueDates.indexOf(e.entry_date)
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
counters.set(e.entry_date, dayLabel)
return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] }
})
}, [entries])
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
// Sync map focus when carousel scrolls (with guard for uninitialized map) // Sync map focus when carousel scrolls (with guard for uninitialized map)
const syncMapToCarousel = useCallback((index: number) => { const syncMapToCarousel = useCallback((index: number) => {
const entry = entries[index] const entry = entries[index]
@@ -76,29 +87,19 @@ export default function MobileMapTimeline({
}) })
}, [syncMapToCarousel]) }, [syncMapToCarousel])
// Track scroll; debounce to re-center the active card when the user stops. // Defer all state updates until scrolling settles — updating activeIndex
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
useEffect(() => { useEffect(() => {
const el = carouselRef.current const el = carouselRef.current
if (!el || entries.length === 0) return if (!el || entries.length === 0) return
let rafId: number | null = null
let settleTimer: number | null = null let settleTimer: number | null = null
const onScroll = () => { const onScroll = () => {
if (rafId != null) return
rafId = requestAnimationFrame(() => {
pickNearestCard()
rafId = null
})
if (settleTimer != null) window.clearTimeout(settleTimer) if (settleTimer != null) window.clearTimeout(settleTimer)
settleTimer = window.setTimeout(() => { settleTimer = window.setTimeout(pickNearestCard, 150)
// Ensure the active card sits at the center once the user settles.
const card = cardRefs.current.get(activeIndexRef.current)
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, 180)
} }
el.addEventListener('scroll', onScroll, { passive: true }) el.addEventListener('scroll', onScroll, { passive: true })
return () => { return () => {
el.removeEventListener('scroll', onScroll) el.removeEventListener('scroll', onScroll)
if (rafId != null) cancelAnimationFrame(rafId)
if (settleTimer != null) window.clearTimeout(settleTimer) if (settleTimer != null) window.clearTimeout(settleTimer)
} }
}, [entries.length, pickNearestCard]) }, [entries.length, pickNearestCard])
@@ -142,7 +143,10 @@ export default function MobileMapTimeline({
if (entries.length === 0) { if (entries.length === 0) {
return ( return (
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}> <div
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<JourneyMap <JourneyMap
ref={mapRef} ref={mapRef}
entries={mapEntries} entries={mapEntries}
@@ -168,7 +172,10 @@ export default function MobileMapTimeline({
} }
return ( return (
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}> <div
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
{/* Full-screen map */} {/* Full-screen map */}
<JourneyMap <JourneyMap
ref={mapRef} ref={mapRef}
@@ -186,13 +193,13 @@ export default function MobileMapTimeline({
{/* Bottom carousel */} {/* Bottom carousel */}
<div <div
className="fixed left-0 right-0 z-40" className="fixed left-0 right-0 z-40"
style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }} style={{ touchAction: 'pan-x', bottom: carouselBottom }}
> >
<div <div
ref={carouselRef} ref={carouselRef}
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth" className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
style={{ style={{
scrollSnapType: 'x proximity', scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch', WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none', scrollbarWidth: 'none',
msOverflowStyle: 'none', msOverflowStyle: 'none',
@@ -207,7 +214,8 @@ export default function MobileMapTimeline({
> >
<MobileEntryCard <MobileEntryCard
entry={entry} entry={entry}
index={i} dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
isActive={i === activeIndex} isActive={i === activeIndex}
onClick={() => handleCardTap(entry, i)} onClick={() => handleCardTap(entry, i)}
publicPhotoUrl={publicPhotoUrl} publicPhotoUrl={publicPhotoUrl}
@@ -0,0 +1,32 @@
export const DAY_COLORS = [
'#6366f1', // indigo
'#f97316', // orange
'#14b8a6', // teal
'#ec4899', // pink
'#22c55e', // green
'#3b82f6', // blue
'#a855f7', // purple
'#ef4444', // red
'#f59e0b', // amber
'#06b6d4', // cyan
'#84cc16', // lime
'#f43f5e', // rose
'#8b5cf6', // violet
'#10b981', // emerald
'#fb923c', // orange-400
'#60a5fa', // blue-400
'#c084fc', // purple-400
'#34d399', // emerald-400
'#fbbf24', // amber-400
'#e879f9', // fuchsia
'#4ade80', // green-400
'#f87171', // red-400
'#38bdf8', // sky-400
'#a3e635', // lime-400
'#fb7185', // rose-400
'#818cf8', // indigo-400
'#2dd4bf', // teal-400
'#facc15', // yellow
'#c026d3', // fuchsia-600
'#0ea5e9', // sky-500
]
@@ -119,13 +119,14 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
...(() => { ...(() => {
const r = ref.current?.getBoundingClientRect() const r = ref.current?.getBoundingClientRect()
if (!r) return { top: 0, left: 0 } if (!r) return { top: 0, left: 0 }
const w = 268, pad = 8 const w = 268, pad = 8, h = 360
const vw = window.innerWidth const vw = window.innerWidth
const vh = window.innerHeight const vh = window.visualViewport?.height ?? window.innerHeight
let left = r.left let left = r.left
let top = r.bottom + 4 let top = r.bottom + 4
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad) if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
if (top + 320 > vh) top = Math.max(pad, r.top - 320) if (top + h > vh - pad) top = r.top - h - 4
top = Math.max(pad, Math.min(top, vh - h - pad))
if (vw < 360) left = Math.max(pad, (vw - w) / 2) if (vw < 360) left = Math.max(pad, (vw - w) / 2)
return { top, left } return { top, left }
})(), })(),
+2
View File
@@ -34,6 +34,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'لا شيء', 'common.none': 'لا شيء',
'common.date': 'التاريخ', 'common.date': 'التاريخ',
'common.rename': 'إعادة تسمية', 'common.rename': 'إعادة تسمية',
'common.discardChanges': 'تجاهل التغييرات',
'common.discard': 'تجاهل',
'common.name': 'الاسم', 'common.name': 'الاسم',
'common.email': 'البريد الإلكتروني', 'common.email': 'البريد الإلكتروني',
'common.password': 'كلمة المرور', 'common.password': 'كلمة المرور',
+2
View File
@@ -30,6 +30,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Nenhum', 'common.none': 'Nenhum',
'common.date': 'Data', 'common.date': 'Data',
'common.rename': 'Renomear', 'common.rename': 'Renomear',
'common.discardChanges': 'Descartar alterações',
'common.discard': 'Descartar',
'common.name': 'Nome', 'common.name': 'Nome',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Senha', 'common.password': 'Senha',
+2
View File
@@ -30,6 +30,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Žádné', 'common.none': 'Žádné',
'common.date': 'Datum', 'common.date': 'Datum',
'common.rename': 'Přejmenovat', 'common.rename': 'Přejmenovat',
'common.discardChanges': 'Zahodit změny',
'common.discard': 'Zahodit',
'common.name': 'Jméno', 'common.name': 'Jméno',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Heslo', 'common.password': 'Heslo',
+2
View File
@@ -30,6 +30,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Keine', 'common.none': 'Keine',
'common.date': 'Datum', 'common.date': 'Datum',
'common.rename': 'Umbenennen', 'common.rename': 'Umbenennen',
'common.discardChanges': 'Änderungen verwerfen',
'common.discard': 'Verwerfen',
'common.name': 'Name', 'common.name': 'Name',
'common.email': 'E-Mail', 'common.email': 'E-Mail',
'common.password': 'Passwort', 'common.password': 'Passwort',
+2
View File
@@ -30,6 +30,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'None', 'common.none': 'None',
'common.date': 'Date', 'common.date': 'Date',
'common.rename': 'Rename', 'common.rename': 'Rename',
'common.discardChanges': 'Discard Changes',
'common.discard': 'Discard',
'common.name': 'Name', 'common.name': 'Name',
'common.email': 'Email', 'common.email': 'Email',
'common.password': 'Password', 'common.password': 'Password',
+2
View File
@@ -30,6 +30,8 @@ const es: Record<string, string> = {
'common.none': 'Ninguno', 'common.none': 'Ninguno',
'common.date': 'Fecha', 'common.date': 'Fecha',
'common.rename': 'Renombrar', 'common.rename': 'Renombrar',
'common.discardChanges': 'Descartar cambios',
'common.discard': 'Descartar',
'common.name': 'Nombre', 'common.name': 'Nombre',
'common.email': 'Correo', 'common.email': 'Correo',
'common.password': 'Contraseña', 'common.password': 'Contraseña',
+2
View File
@@ -30,6 +30,8 @@ const fr: Record<string, string> = {
'common.none': 'Aucun', 'common.none': 'Aucun',
'common.date': 'Date', 'common.date': 'Date',
'common.rename': 'Renommer', 'common.rename': 'Renommer',
'common.discardChanges': 'Ignorer les modifications',
'common.discard': 'Ignorer',
'common.name': 'Nom', 'common.name': 'Nom',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Mot de passe', 'common.password': 'Mot de passe',
+2
View File
@@ -30,6 +30,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Nincs', 'common.none': 'Nincs',
'common.date': 'Dátum', 'common.date': 'Dátum',
'common.rename': 'Átnevezés', 'common.rename': 'Átnevezés',
'common.discardChanges': 'Változtatások elvetése',
'common.discard': 'Elveti',
'common.name': 'Név', 'common.name': 'Név',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Jelszó', 'common.password': 'Jelszó',
+2
View File
@@ -30,6 +30,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Tidak ada', 'common.none': 'Tidak ada',
'common.date': 'Tanggal', 'common.date': 'Tanggal',
'common.rename': 'Ganti nama', 'common.rename': 'Ganti nama',
'common.discardChanges': 'Buang perubahan',
'common.discard': 'Buang',
'common.name': 'Nama', 'common.name': 'Nama',
'common.email': 'Email', 'common.email': 'Email',
'common.password': 'Kata sandi', 'common.password': 'Kata sandi',
+2
View File
@@ -30,6 +30,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Nessuno', 'common.none': 'Nessuno',
'common.date': 'Data', 'common.date': 'Data',
'common.rename': 'Rinomina', 'common.rename': 'Rinomina',
'common.discardChanges': 'Scarta modifiche',
'common.discard': 'Scarta',
'common.name': 'Nome', 'common.name': 'Nome',
'common.email': 'Email', 'common.email': 'Email',
'common.password': 'Password', 'common.password': 'Password',
+2
View File
@@ -30,6 +30,8 @@ const nl: Record<string, string> = {
'common.none': 'Geen', 'common.none': 'Geen',
'common.date': 'Datum', 'common.date': 'Datum',
'common.rename': 'Hernoemen', 'common.rename': 'Hernoemen',
'common.discardChanges': 'Wijzigingen verwerpen',
'common.discard': 'Verwerpen',
'common.name': 'Naam', 'common.name': 'Naam',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Wachtwoord', 'common.password': 'Wachtwoord',
+2
View File
@@ -26,6 +26,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Brak', 'common.none': 'Brak',
'common.date': 'Data', 'common.date': 'Data',
'common.rename': 'Zmień nazwę', 'common.rename': 'Zmień nazwę',
'common.discardChanges': 'Odrzuć zmiany',
'common.discard': 'Odrzuć',
'common.name': 'Nazwa', 'common.name': 'Nazwa',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Hasło', 'common.password': 'Hasło',
+2
View File
@@ -30,6 +30,8 @@ const ru: Record<string, string> = {
'common.none': 'Нет', 'common.none': 'Нет',
'common.date': 'Дата', 'common.date': 'Дата',
'common.rename': 'Переименовать', 'common.rename': 'Переименовать',
'common.discardChanges': 'Отменить изменения',
'common.discard': 'Отменить',
'common.name': 'Имя', 'common.name': 'Имя',
'common.email': 'Эл. почта', 'common.email': 'Эл. почта',
'common.password': 'Пароль', 'common.password': 'Пароль',
+2
View File
@@ -30,6 +30,8 @@ const zh: Record<string, string> = {
'common.none': '无', 'common.none': '无',
'common.date': '日期', 'common.date': '日期',
'common.rename': '重命名', 'common.rename': '重命名',
'common.discardChanges': '放弃更改',
'common.discard': '放弃',
'common.name': '名称', 'common.name': '名称',
'common.email': '邮箱', 'common.email': '邮箱',
'common.password': '密码', 'common.password': '密码',
+2
View File
@@ -30,6 +30,8 @@ const zhTw: Record<string, string> = {
'common.none': '無', 'common.none': '無',
'common.date': '日期', 'common.date': '日期',
'common.rename': '重新命名', 'common.rename': '重新命名',
'common.discardChanges': '捨棄變更',
'common.discard': '捨棄',
'common.name': '名稱', 'common.name': '名稱',
'common.email': '郵箱', 'common.email': '郵箱',
'common.password': '密碼', 'common.password': '密碼',
+4 -4
View File
@@ -1468,7 +1468,7 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-074 ────────────────────────────────────────── // ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => { describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
it('clicking "Remove share link" calls DELETE and returns to create state', async () => { it('clicking "Delete link" calls DELETE and returns to create state', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let deleteCalled = false; let deleteCalled = false;
@@ -1493,10 +1493,10 @@ describe('JourneyDetailPage', () => {
await openSettingsDialog(user); await openSettingsDialog(user);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Remove share link')).toBeInTheDocument(); expect(screen.getByText('Delete link')).toBeInTheDocument();
}); });
await user.click(screen.getByText('Remove share link')); await user.click(screen.getByText('Delete link'));
await waitFor(() => { await waitFor(() => {
expect(deleteCalled).toBe(true); expect(deleteCalled).toBe(true);
@@ -2905,7 +2905,7 @@ describe('JourneyDetailPage', () => {
// The permission toggles show Timeline, Gallery, Map labels within the share section // The permission toggles show Timeline, Gallery, Map labels within the share section
// These reuse the same i18n keys as the main tab bar // These reuse the same i18n keys as the main tab bar
expect(screen.getByText('Remove share link')).toBeInTheDocument(); expect(screen.getByText('Delete link')).toBeInTheDocument();
expect(screen.getByText('Copy')).toBeInTheDocument(); expect(screen.getByText('Copy')).toBeInTheDocument();
}); });
}); });
+68 -26
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { formatLocationName } from '../utils/formatters'
import { createPortal } from 'react-dom' 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'
@@ -8,6 +9,7 @@ import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client'
import { addListener, removeListener } from '../api/websocket' import { addListener, removeListener } from '../api/websocket'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import JourneyMap from '../components/Journey/JourneyMapAuto' import JourneyMap from '../components/Journey/JourneyMapAuto'
import { DAY_COLORS } from '../components/Journey/dayColors'
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto' import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
import JournalBody from '../components/Journey/JournalBody' import JournalBody from '../components/Journey/JournalBody'
import MarkdownToolbar from '../components/Journey/MarkdownToolbar' import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
@@ -188,7 +190,9 @@ export default function JourneyDetailPage() {
const winner = lastPast || firstAhead const winner = lastPast || firstAhead
if (winner) { if (winner) {
setActiveEntryId(winner.id) setActiveEntryId(winner.id)
mapRef.current?.highlightMarker(winner.id) if (locatedEntryIdsRef.current.has(winner.id)) {
mapRef.current?.highlightMarker(winner.id)
}
} }
} }
const onScroll = () => { const onScroll = () => {
@@ -279,16 +283,38 @@ export default function JourneyDetailPage() {
[current?.entries] [current?.entries]
) )
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({ const sidebarMapItems = useMemo(() => {
id: String(e.id), const allDates = [...new Set(
lat: e.location_lat!, (current?.entries || [])
lng: e.location_lng!, .filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
title: e.title || '', .map(e => e.entry_date)
location_name: e.location_name || '', .sort()
mood: e.mood, )]
created_at: e.entry_date, const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
entry_date: e.entry_date, const dayCounters = new Map<string, number>()
})), [mapEntries]) return sorted.map(e => {
const dayIdx = allDates.indexOf(e.entry_date)
const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1
dayCounters.set(e.entry_date, dayLabel)
return {
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
location_name: e.location_name || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
}
})
}, [mapEntries, current?.entries])
const locatedEntryIdsRef = useRef(new Set<string>())
useEffect(() => {
locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id))
}, [sidebarMapItems])
const tripDates = useMemo(() => { const tripDates = useMemo(() => {
const dates = new Set<string>() const dates = new Set<string>()
@@ -424,7 +450,7 @@ export default function JourneyDetailPage() {
? 'max-w-[1440px] mx-auto px-0 pt-0' ? 'max-w-[1440px] mx-auto px-0 pt-0'
: 'flex w-full overflow-hidden' : 'flex w-full overflow-hidden'
} }
style={!isMobile ? { height: 'calc(100vh - var(--nav-h, 56px))' } : undefined} style={!isMobile ? { height: 'calc(100dvh - var(--nav-h, 56px))' } : undefined}
> >
{/* LEFT column (full width on mobile, scrollable feed on desktop) */} {/* LEFT column (full width on mobile, scrollable feed on desktop) */}
<div <div
@@ -484,7 +510,7 @@ export default function JourneyDetailPage() {
> >
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />} {hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
</button> </button>
<span className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity"> <span className="absolute top-full mt-2 right-0 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')} {hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
</span> </span>
</div> </div>
@@ -584,7 +610,7 @@ export default function JourneyDetailPage() {
<div key={date} className="flex flex-col gap-3 trek-stagger"> <div key={date} className="flex flex-col gap-3 trek-stagger">
<div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between"> <div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold"> <div className="w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-bold text-white" style={{ background: DAY_COLORS[dayIdx % DAY_COLORS.length] }}>
{dayIdx + 1} {dayIdx + 1}
</div> </div>
<div> <div>
@@ -613,7 +639,7 @@ export default function JourneyDetailPage() {
.catch(() => toast.error(t('common.errorOccurred'))) .catch(() => toast.error(t('common.errorOccurred')))
} }
return ( return (
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`}> <div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`} onMouseEnter={() => { setActiveEntryId(String(entry.id)); mapRef.current?.highlightMarker(String(entry.id)) }} style={String(entry.id) === activeEntryId ? { outline: `2px solid ${DAY_COLORS[dayIdx % DAY_COLORS.length]}`, outlineOffset: '3px', borderRadius: '12px' } : undefined}>
{canReorder && ( {canReorder && (
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1"> <div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
<button <button
@@ -735,7 +761,8 @@ export default function JourneyDetailPage() {
journey={current} journey={current}
onClose={() => setShowSettings(false)} onClose={() => setShowSettings(false)}
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }} onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
onOpenInvite={() => { setShowSettings(false); setShowInvite(true) }} onOpenInvite={() => { setShowInvite(true) }}
onRefresh={() => loadJourney(Number(id))}
/> />
)} )}
@@ -917,7 +944,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span> <span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
</div> </div>
<div className="text-[11px] text-zinc-500 truncate"> <div className="text-[11px] text-zinc-500 truncate">
{e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''} {formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''}
</div> </div>
</div> </div>
@@ -1360,7 +1387,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
{entry.location_name && ( {entry.location_name && (
<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 tracking-wide max-w-full overflow-hidden"> <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 tracking-wide max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" /> <MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{entry.location_name}</span> <span className="truncate">{formatLocationName(entry.location_name)}</span>
</span> </span>
)} )}
{entry.entry_time && ( {entry.entry_time && (
@@ -1403,7 +1430,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2"> <div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
{entry.location_name && ( {entry.location_name && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden"> <span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{entry.location_name}</span> <MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{formatLocationName(entry.location_name)}</span>
</span> </span>
)} )}
{entry.entry_time && ( {entry.entry_time && (
@@ -1482,7 +1509,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () =>
{entry.title || t('journey.detail.newEntry')} {entry.title || t('journey.detail.newEntry')}
</div> </div>
<div className="text-[11px] text-zinc-500 mt-0.5"> <div className="text-[11px] text-zinc-500 mt-0.5">
{entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''} {formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
</div> </div>
</div> </div>
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0"> <div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
@@ -2954,7 +2981,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
onClick={deleteLink} onClick={deleteLink}
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start" className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
> >
Remove share link {t('share.deleteLink')}
</button> </button>
</div> </div>
)} )}
@@ -2962,11 +2989,12 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
) )
} }
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: { function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
journey: JourneyDetail journey: JourneyDetail
onClose: () => void onClose: () => void
onSaved: () => void onSaved: () => void
onOpenInvite: () => void onOpenInvite: () => void
onRefresh: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const [title, setTitle] = useState(journey.title) const [title, setTitle] = useState(journey.title)
@@ -2974,6 +3002,10 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [showAddTrip, setShowAddTrip] = useState(false) const [showAddTrip, setShowAddTrip] = useState(false)
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null) const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
const isDirty = title !== journey.title || subtitle !== (journey.subtitle || '')
const handleClose = () => { if (isDirty) setShowDiscardConfirm(true); else onClose() }
const coverRef = useRef<HTMLInputElement>(null) const coverRef = useRef<HTMLInputElement>(null)
const toast = useToast() const toast = useToast()
const navigate = useNavigate() const navigate = useNavigate()
@@ -3032,12 +3064,12 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
} }
return ( return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}> <div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}> <div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<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">{t('journey.settings.title')}</h2> <h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"> <button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
@@ -3133,7 +3165,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
try { try {
await journeyApi.removeContributor(journey.id, c.user_id) await journeyApi.removeContributor(journey.id, c.user_id)
toast.success(t('journey.contributors.removed')) toast.success(t('journey.contributors.removed'))
onSaved() onRefresh()
} catch { } catch {
toast.error(t('journey.contributors.removeFailed')) toast.error(t('journey.contributors.removeFailed'))
} }
@@ -3184,7 +3216,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />} {journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span> <span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
</button> </button>
<button onClick={onClose} className="h-9 px-3.5 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={handleClose} className="h-9 px-3.5 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 || !title.trim()} className="h-9 px-3.5 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-40"> <button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 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-40">
{saving ? t('common.saving') : t('common.save')} {saving ? t('common.saving') : t('common.save')}
</button> </button>
@@ -3231,6 +3263,16 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
confirmLabel={t('common.delete')} confirmLabel={t('common.delete')}
danger danger
/> />
<ConfirmDialog
isOpen={showDiscardConfirm}
onClose={() => setShowDiscardConfirm(false)}
onConfirm={() => { setShowDiscardConfirm(false); onClose() }}
title={t('common.discardChanges')}
message={t('journey.editor.discardChangesConfirm')}
confirmLabel={t('common.discard')}
danger
/>
</div> </div>
) )
} }
+11 -25
View File
@@ -234,28 +234,20 @@ describe('JourneyPublicPage', () => {
} }
}); });
it('FE-PAGE-PUBLICJOURNEY-009: map tab switches view', async () => { it('FE-PAGE-PUBLICJOURNEY-009: map is always visible in desktop two-column layout', async () => {
setupSuccess(); setupSuccess();
render(<JourneyPublicPage />); render(<JourneyPublicPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
}); });
const buttons = screen.getAllByRole('button'); // Desktop two-column: map sidebar is always rendered alongside the timeline;
const mapBtn = buttons.find( // there is no standalone "Map" tab button on desktop.
btn => btn.textContent && /map/i.test(btn.textContent), await waitFor(() => {
); expect(screen.getByTestId('journey-map')).toBeInTheDocument();
expect(mapBtn).toBeDefined(); });
if (mapBtn) { // Timeline entries remain visible (two-column shows both simultaneously)
fireEvent.click(mapBtn); expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
// After clicking map tab, the timeline entries should no longer be visible
// and the map view content should be rendered (even if JourneyMap errors internally
// due to jsdom limitations, the tab state switches)
await waitFor(() => {
// Shibuya Crossing (timeline-only) should not appear once map is active
expect(screen.queryByText('Shibuya Crossing')).not.toBeInTheDocument();
});
}
}); });
it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => { it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => {
@@ -303,24 +295,18 @@ describe('JourneyPublicPage', () => {
}); });
// FE-PAGE-PUBLICJOURNEY-012 // FE-PAGE-PUBLICJOURNEY-012
it('FE-PAGE-PUBLICJOURNEY-012: tab switching from timeline to map shows map component', async () => { it('FE-PAGE-PUBLICJOURNEY-012: map component renders with located entries in desktop two-column layout', async () => {
const user = userEvent.setup();
setupSuccess(); setupSuccess();
render(<JourneyPublicPage />); render(<JourneyPublicPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
}); });
const mapBtn = screen.getAllByRole('button').find( // Desktop two-column: map sidebar is always rendered; no tab click required.
btn => btn.textContent && /map/i.test(btn.textContent),
);
expect(mapBtn).toBeDefined();
await user.click(mapBtn!);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('journey-map')).toBeInTheDocument(); expect(screen.getByTestId('journey-map')).toBeInTheDocument();
}); });
// Map receives entries with lat/lng // Both fixture entries have coordinates → map receives 2 located entries
expect(screen.getByTestId('journey-map').textContent).toContain('2'); expect(screen.getByTestId('journey-map').textContent).toContain('2');
}); });
+396 -157
View File
@@ -1,14 +1,22 @@
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { journeyApi } from '../api/client' import { journeyApi } from '../api/client'
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n' import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
import { useSettingsStore } from '../store/settingsStore' 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 JourneyMap from '../components/Journey/JourneyMap'
import type { JourneyMapHandle } 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 MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import { useIsMobile } from '../hooks/useIsMobile' import { useIsMobile } from '../hooks/useIsMobile'
import { formatLocationName } from '../utils/formatters'
import { DAY_COLORS } from '../components/Journey/dayColors'
interface PublicEntry { interface PublicEntry {
id: number id: number
@@ -36,6 +44,22 @@ interface PublicPhoto {
caption?: string | null 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 { function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}` return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
} }
@@ -70,6 +94,15 @@ export default function JourneyPublicPage() {
const { t } = useTranslation() const { t } = useTranslation()
const [showLangPicker, setShowLangPicker] = useState(false) const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en' const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null)
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const handleMarkerClick = useCallback((entryId: string) => {
setActiveEntryId(entryId)
mapRef.current?.highlightMarker(entryId)
document.querySelector(`[data-entry-id="${entryId}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [])
useEffect(() => { useEffect(() => {
if (!token) return if (!token) return
@@ -84,10 +117,6 @@ export default function JourneyPublicPage() {
const journey = data?.journey || {} const journey = data?.journey || {}
const stats = data?.stats || {} 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( const timelineEntries = useMemo(
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'), () => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
[entries], [entries],
@@ -100,12 +129,43 @@ export default function JourneyPublicPage() {
) )
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries]) const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
// Map entries with day color/label for colored markers.
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
// stay in sync with the timeline day headers even when some days have no locations.
const sidebarMapItems = useMemo(() => {
const counters = new Map<string, number>()
return mapEntries.map(e => {
const dayIdx = sortedDates.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, sortedDates])
// 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 // Set default view based on permissions
useEffect(() => { useEffect(() => {
if (!perms.share_timeline && perms.share_gallery) setView('gallery') if (!perms.share_timeline && perms.share_gallery) setView('gallery')
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map') else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
}, [perms]) }, [perms])
// When switching to desktop two-column, 'map' standalone tab no longer exists
useEffect(() => {
if (desktopTwoColumn && view === 'map') setView('timeline')
}, [desktopTwoColumn, view])
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center"> <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
@@ -125,21 +185,262 @@ export default function JourneyPublicPage() {
) )
} }
// In desktop two-column mode the map is always visible — exclude the standalone 'map' tab
const availableViews = [ const availableViews = [
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, 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_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 }[] ].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 }))
const isActive = activeEntryId === String(entry.id)
return (
<div
key={entry.id}
data-entry-id={String(entry.id)}
onMouseEnter={() => {
if (!desktopTwoColumn) return
setActiveEntryId(String(entry.id))
mapRef.current?.highlightMarker(String(entry.id))
}}
style={isActive && desktopTwoColumn ? { outline: `2px solid ${dayColor}`, outlineOffset: '3px', borderRadius: '16px' } : undefined}
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 gap-3 mt-4 ${prosArr.length > 0 && consArr.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{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 ( 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">
{/* Hero */} {/* Hero */}
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}> <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 && ( {journey.cover_image && (
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} /> <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', 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)' }} /> <div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
@@ -194,160 +495,98 @@ export default function JourneyPublicPage() {
</div> </div>
{/* Content */} {/* Content */}
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6"> {desktopTwoColumn ? (
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
{/* View tabs */} <div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}>
{availableViews.length > 1 && ( {/* Left: feed */}
<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"> <div className="flex-1 min-w-0 px-8 py-6">
{availableViews.map(v => ( {renderTabs(availableViews)}
<button {view === 'timeline' && perms.share_timeline && renderTimeline()}
key={v.id} {view === 'gallery' && perms.share_gallery && renderGallery()}
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>
)}
{/* Mobile combined map+timeline (public, read-only) */} {/* Right: sticky map — matches auth page aside proportions */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && ( <aside
<MobileMapTimeline className="flex-shrink-0"
entries={timelineEntries} style={{
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 }))} width: '44%', minWidth: 420, maxWidth: 760,
dark={document.documentElement.classList.contains('dark')} position: 'sticky', top: 0, height: '100dvh',
readOnly padding: '16px 16px 16px 0',
onEntryClick={() => {}} alignSelf: 'flex-start',
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} }}
/> >
)} <div className="h-full rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-800 shadow-sm">
<JourneyMap
ref={mapRef}
checkins={[]}
entries={sidebarMapItems as any}
height={9999}
fullScreen
activeMarkerId={activeEntryId ?? undefined}
onMarkerClick={handleMarkerClick}
/>
</div>
</aside>
</div>
) : (
// ── Single-column layout (mobile + desktop-without-map) ───────────────
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
{/* Timeline (desktop, or mobile without map permission) */} {/* Floating view toggle — visible above the fullscreen map on mobile */}
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && ( {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && (
<div className="flex flex-col gap-6"> <div className="fixed left-0 right-0 z-50 flex justify-center px-4" style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}>
{sortedDates.map(date => { <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">
const dayEntries = groupedEntries.get(date)! {availableViews.map(v => (
const fd = formatDate(date, locale) <button
return ( key={v.id}
<div key={date}> onClick={() => setView(v.id)}
<div className="flex items-center gap-3 mb-4"> className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
<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> view === v.id
<div> ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div> : 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div> }`}
</div> >
</div> <v.icon size={13} />
<div className="flex flex-col gap-4 pl-[52px]"> {v.label}
{dayEntries.map(entry => ( </button>
<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" />
</div> </div>
))} </div>
</div> )}
)}
{/* Map */} {renderTabs(availableViews)}
{view === 'map' && perms.share_map && (
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700"> {/* Mobile combined map+timeline (public, read-only) */}
<JourneyMap {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
checkins={[]} <MobileMapTimeline
entries={mapEntries.map(e => ({ entries={timelineEntries}
id: String(e.id), mapEntries={sidebarMapItems as any}
lat: e.location_lat!, dark={document.documentElement.classList.contains('dark')}
lng: e.location_lng!, readOnly
title: e.title || '', onEntryClick={() => {}}
mood: e.mood, publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
created_at: e.entry_date, carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
entry_date: e.entry_date,
})) as any}
height={500}
/> />
</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 */} {/* Powered by */}
<div className="flex flex-col items-center py-8 gap-2"> <div className="flex flex-col items-center py-8 gap-2">
+33
View File
@@ -1,5 +1,38 @@
import type { AssignmentsMap } from '../types' import type { AssignmentsMap } from '../types'
// Collapses verbose Nominatim display_name strings (e.g. "Place, 1, Road, Neighbourhood,
// City, County, State, Country, Postcode, Country") into "Place, Postcode, Country".
// Clean short names (≤3 parts) pass through untouched.
export function formatLocationName(raw: string | null | undefined): string {
if (!raw) return ''
const parts = raw.split(',').map(p => p.trim()).filter(Boolean)
if (parts.length <= 3) return raw.trim()
// Dedup preserving insertion order
const seen = new Set<string>()
const unique: string[] = []
for (const p of parts) {
if (!seen.has(p.toLowerCase())) { seen.add(p.toLowerCase()); unique.push(p) }
}
if (unique.length <= 3) return unique.join(', ')
const name = unique[0]
const last = unique[unique.length - 1]
const secondLast = unique.length >= 2 ? unique[unique.length - 2] : null
// Detect postcode at tail: short alphanumeric with at least one digit, ≤10 chars
const postalRe = /^[A-Z0-9][A-Z0-9\s\-]{1,8}$/i
const isLastPostal = postalRe.test(last) && /\d/.test(last) && last.length <= 10
const postcode = isLastPostal ? last : null
const country = isLastPostal ? secondLast : last
const result: string[] = [name]
if (postcode && postcode !== name) result.push(postcode)
if (country && country !== name && country !== postcode) result.push(country)
return result.join(', ')
}
const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF']) const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF'])
export function currencyDecimals(currency: string): number { export function currencyDecimals(currency: string): number {