mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
fix(journey): resolve issues #789-801 — mobile layout, day colors, location formatting, date picker, public share UX
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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,10 +36,22 @@ 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 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())
|
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||||
const activeIndexRef = useRef(activeIndex)
|
const activeIndexRef = useRef(activeIndex)
|
||||||
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
|
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
|
||||||
@@ -142,7 +156,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 +185,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,7 +206,7 @@ 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}
|
||||||
@@ -207,7 +227,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,12 @@
|
|||||||
|
export const DAY_COLORS = [
|
||||||
|
'#6366f1',
|
||||||
|
'#f97316',
|
||||||
|
'#14b8a6',
|
||||||
|
'#ec4899',
|
||||||
|
'#22c55e',
|
||||||
|
'#3b82f6',
|
||||||
|
'#a855f7',
|
||||||
|
'#ef4444',
|
||||||
|
'#f59e0b',
|
||||||
|
'#06b6d4',
|
||||||
|
]
|
||||||
@@ -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 }
|
||||||
})(),
|
})(),
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -279,16 +281,28 @@ export default function JourneyDetailPage() {
|
|||||||
[current?.entries]
|
[current?.entries]
|
||||||
)
|
)
|
||||||
|
|
||||||
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({
|
const sidebarMapItems = useMemo(() => {
|
||||||
id: String(e.id),
|
const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
|
||||||
lat: e.location_lat!,
|
const uniqueDates = [...new Set(sorted.map(e => e.entry_date))]
|
||||||
lng: e.location_lng!,
|
const dayCounters = new Map<string, number>()
|
||||||
title: e.title || '',
|
return sorted.map(e => {
|
||||||
location_name: e.location_name || '',
|
const dayIdx = uniqueDates.indexOf(e.entry_date)
|
||||||
mood: e.mood,
|
const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1
|
||||||
created_at: e.entry_date,
|
dayCounters.set(e.entry_date, dayLabel)
|
||||||
entry_date: e.entry_date,
|
return {
|
||||||
})), [mapEntries])
|
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])
|
||||||
|
|
||||||
const tripDates = useMemo(() => {
|
const tripDates = useMemo(() => {
|
||||||
const dates = new Set<string>()
|
const dates = new Set<string>()
|
||||||
@@ -424,7 +438,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 +498,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 +598,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 +627,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 +749,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 +932,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 +1375,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 +1418,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 +1497,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">
|
||||||
@@ -2962,11 +2977,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)
|
||||||
@@ -3133,7 +3149,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'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,28 @@ export default function JourneyPublicPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Floating view toggle — visible above the fullscreen map on mobile */}
|
||||||
|
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && (
|
||||||
|
<div className="fixed left-0 right-0 z-50 flex justify-center px-4" style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}>
|
||||||
|
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
||||||
|
{availableViews.map(v => (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
onClick={() => setView(v.id)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||||
|
view === v.id
|
||||||
|
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<v.icon size={13} />
|
||||||
|
{v.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mobile combined map+timeline (public, read-only) */}
|
{/* Mobile combined map+timeline (public, read-only) */}
|
||||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
||||||
<MobileMapTimeline
|
<MobileMapTimeline
|
||||||
@@ -225,6 +247,7 @@ export default function JourneyPublicPage() {
|
|||||||
readOnly
|
readOnly
|
||||||
onEntryClick={() => {}}
|
onEntryClick={() => {}}
|
||||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||||
|
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user