Files
TREK/client/src/components/Journey/MobileMapTimeline.tsx
T
Maurice 15aefa449e feat(journey): the marker card, and what testing it turned up
Everything here came out of putting the first commit in front of somebody and
watching them use it.

**The marker card is one card now, and it carries the entry's photos.** Leaflet
showed a one-line label and the GL map a two-line card, so the same marker said
different things depending on a renderer setting nobody connects to journeys.
Both now build from one module: a strip of up to three thumbnails bleeding to
the card's edges, the name, and the place and date as chips. On the GL map the
thumbnails open the entry's photos; the rest of the card still never takes the
pointer, so the map drags from anywhere on it.

**A pin that would not sit still.** Dragging the map left the marker trailing
behind and snapping back at the end. The app's press-feedback rule puts a 180ms
transition on `[role="button"]`, MapLibre and Mapbox give their marker element
exactly that role, and both rewrite its `transform` on every frame of a
movement. Markers are excluded now — every map in TREK, not just this one.

**The phone card, after four rounds of looking at it.** The day is the card's
own edge rather than a bar across its foot, and the ring is inset because the
carousel clips. A card without a photo is opaque: it was a wash of the day's
colour over the map. The country is a real flag rather than two letters —
Windows has no flag glyphs, so the emoji drew "DE" where a flag was meant, and
the image falls back to the emoji when it cannot be fetched. Mood, weather and
the date share the top edge; the flag sits opposite them.

**The day bar answers to a finger.** Press the date and slide sideways to run
through the days. The bar is still a row of buttons underneath, because that is
the only version of it a keyboard can reach, and each one is 20px tall now
rather than 3.

Also here:

- Videos upload to an entry (issue #2341). The gallery has taken them since
  #823 and an entry could not: everything went through the images-only filter
  and came back 400, reported as "1 of 1 photos failed". Same two-part upload,
  same absence of transcoding, and the editor now previews a clip in a `<video>`
  instead of a broken-image glyph.
- The search box takes the gap the toolbar already had instead of clipping its
  own placeholder.
- Entry cards line up with the day header again: the reorder arrows moved out of
  a column of their own into the feed's padding and appear on hover, and the
  day-coloured ring on the active card is a layer over it — an outline tight
  enough to sit on the card's edge was painting behind it.
- Map attribution is collapsed to its ⓘ. It cannot go away — the OSM licence
  asks for it — but it does not have to lie under the carousel.
- The suggestion dismiss and the day-header plus use TREK's own tooltip rather
  than the browser's.
2026-09-13 19:52:14 +02:00

291 lines
10 KiB
TypeScript

import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap'
import JourneyEntryCover from './JourneyEntryCover'
import JourneyDayScrubber from './JourneyDayScrubber'
import { dayColorOf, journeyDays } from './journeyCard'
import type { JourneyMapHandle } from './JourneyMap'
import type { JourneyEntry } from '../../store/journeyStore'
import type { JourneyTrack } from '@trek/shared'
interface MapEntry {
id: string
lat: number
lng: number
title?: string | null
mood?: string | null
entry_date: string
}
interface Props {
entries: JourneyEntry[] | any[]
mapEntries: MapEntry[]
trail?: { lat: number; lng: number }[]
tracks?: JourneyTrack[]
dark?: boolean
readOnly?: boolean
onEntryClick: (entry: any) => void
onAddEntry?: () => void
publicPhotoUrl?: (photoId: number) => string
carouselBottom?: string
/** CARTO key from the share payload, forwarded to the map (#2054). */
cartoApiKey?: string
/** Off when the journey has put that field away (journey settings). */
showMood?: boolean
showWeather?: boolean
/** Which entry to open on. Today's, when today is part of the journey (#2299). */
initialEntryId?: string | null
}
export default function MobileMapTimeline({
entries,
mapEntries,
trail,
tracks,
dark,
readOnly,
onEntryClick,
onAddEntry,
publicPhotoUrl,
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
cartoApiKey,
showMood = true,
showWeather = true,
initialEntryId,
}: Props) {
const mapRef = useRef<JourneyMapHandle>(null)
const carouselRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0)
const scrubberDays = useMemo(() => journeyDays(entries), [entries])
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
// Sync map focus when carousel scrolls (with guard for uninitialized map)
const syncMapToCarousel = useCallback((index: number) => {
const entry = entries[index]
if (!entry) return
const mapEntry = mapEntries.find(m => String(m.id) === String(entry.id))
if (mapEntry) {
try { mapRef.current?.focusMarker(String(mapEntry.id)) } catch {}
} else {
try { mapRef.current?.highlightMarker(null) } catch {}
}
}, [entries, mapEntries])
// The delayed initial focus reads both through refs so it always works off
// the current map entries, not the ones from the render that armed the timer.
const syncMapToCarouselRef = useRef(syncMapToCarousel)
syncMapToCarouselRef.current = syncMapToCarousel
const activeIndexRef = useRef(activeIndex)
activeIndexRef.current = activeIndex
// Pick the card that's currently closest to the carousel horizontal center.
// More stable than IntersectionObserver thresholds when the active card can
// drift toward the viewport edge with proximity snapping.
const pickNearestCard = useCallback(() => {
const el = carouselRef.current
if (!el) return
const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2
let bestIdx = 0
let bestDist = Infinity
cardRefs.current.forEach((node, idx) => {
const r = node.getBoundingClientRect()
const cardCenter = r.left + r.width / 2
const d = Math.abs(cardCenter - containerCenter)
if (d < bestDist) { bestDist = d; bestIdx = idx }
})
setActiveIndex(prev => {
if (prev !== bestIdx) syncMapToCarousel(bestIdx)
return bestIdx
})
}, [syncMapToCarousel])
// Defer all state updates until scrolling settles — updating activeIndex
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
useEffect(() => {
const el = carouselRef.current
if (!el || entries.length === 0) return
let settleTimer: number | null = null
const onScroll = () => {
if (settleTimer != null) window.clearTimeout(settleTimer)
settleTimer = window.setTimeout(pickNearestCard, 150)
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('scroll', onScroll)
if (settleTimer != null) window.clearTimeout(settleTimer)
}
}, [entries.length, pickNearestCard])
// Scroll a given card into the horizontal center of the carousel
const scrollCardIntoCenter = useCallback((idx: number) => {
const card = cardRefs.current.get(idx)
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, [])
// Scroll carousel to entry when map marker is clicked
const handleMarkerClick = useCallback((id: string) => {
const idx = entries.findIndex((e: any) => String(e.id) === id)
if (idx === -1) return
setActiveIndex(idx)
scrollCardIntoCenter(idx)
}, [entries, scrollCardIntoCenter])
// Tap on a card: if it's already active, open the edit view; otherwise
// activate + center it first (don't jump straight into the editor).
const handleCardTap = useCallback((entry: any, idx: number) => {
if (idx === activeIndex) {
onEntryClick(entry)
} else {
setActiveIndex(idx)
scrollCardIntoCenter(idx)
}
}, [activeIndex, onEntryClick, scrollCardIntoCenter])
// The day bar lands on the first entry of that day, which is where a reader
// who asked for "day nine" means.
const jumpToDay = useCallback((date: string) => {
const idx = entries.findIndex((e: any) => e.entry_date === date)
if (idx === -1) return
setActiveIndex(idx)
syncMapToCarousel(idx)
scrollCardIntoCenter(idx)
}, [entries, scrollCardIntoCenter, syncMapToCarousel])
// Initial map focus — delay to let Leaflet initialize and fitBounds. Also
// re-runs when the markers arrive later than the entries, otherwise the
// focus would fire against an empty map and never be retried.
const openedRef = useRef(false)
useEffect(() => {
if (entries.length === 0) return
const timer = setTimeout(() => {
if (!openedRef.current && initialEntryId) {
openedRef.current = true
const idx = entries.findIndex((e: any) => String(e.id) === initialEntryId)
if (idx > 0) {
setActiveIndex(idx)
cardRefs.current.get(idx)?.scrollIntoView({ inline: 'center', block: 'nearest' })
syncMapToCarouselRef.current(idx)
return
}
}
syncMapToCarouselRef.current(activeIndexRef.current)
}, 500)
return () => clearTimeout(timer)
}, [entries.length, mapEntries.length, initialEntryId])
const activeEntryId = entries[activeIndex]
? String(entries[activeIndex].id)
: null
if (entries.length === 0) {
return (
<div
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<JourneyMap
ref={mapRef}
entries={mapEntries}
checkins={[]}
trail={trail}
tracks={tracks}
height={9999}
dark={dark}
onMarkerClick={handleMarkerClick}
fullScreen
cartoApiKey={cartoApiKey}
/>
{!readOnly && onAddEntry && (
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
<button type="button"
onClick={onAddEntry}
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
>
<Plus size={20} />
</button>
</div>
)}
</div>
)
}
return (
<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 */}
<JourneyMap
ref={mapRef}
entries={mapEntries}
checkins={[]}
trail={trail}
tracks={tracks}
height={9999}
dark={dark}
activeMarkerId={activeEntryId}
onMarkerClick={handleMarkerClick}
fullScreen
paddingBottom={250}
cartoApiKey={cartoApiKey}
/>
{/* Day bar + card carousel, as one block at the bottom of the map */}
<div
className="fixed left-0 right-0 z-40"
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
>
<JourneyDayScrubber
days={scrubberDays}
activeDate={entries[activeIndex]?.entry_date ?? null}
onPick={jumpToDay}
/>
<div
ref={carouselRef}
className="flex items-end gap-[10px] overflow-x-auto px-4 pb-3 pt-1"
style={{
scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
>
{entries.map((entry: any, i: number) => (
<div
key={entry.id}
data-idx={i}
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
style={{ scrollSnapAlign: 'center' }}
>
<JourneyEntryCover
entry={entry}
dayColor={dayColorOf(scrubberDays, entry.entry_date)}
isActive={i === activeIndex}
onClick={() => handleCardTap(entry, i)}
showMood={showMood}
showWeather={showWeather}
photoUrlFor={publicPhotoUrl}
/>
</div>
))}
</div>
</div>
{/* FAB: add entry — bottom right, above the timeline carousel */}
{!readOnly && onAddEntry && (
<div
className="fixed right-4 z-30"
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 226px)' }}
>
<button type="button"
onClick={onAddEntry}
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
>
<Plus size={20} />
</button>
</div>
)}
</div>
)
}