mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
15aefa449e
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.
291 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|