File size: 8,250 Bytes
766d85d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap'
import MobileEntryCard from './MobileEntryCard'
import type { JourneyMapHandle } from './JourneyMap'
import type { JourneyEntry } from '../../store/journeyStore'
import { DAY_COLORS } from './dayColors'

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 }[]
  dark?: boolean
  readOnly?: boolean
  onEntryClick: (entry: any) => void
  onAddEntry?: () => void
  publicPhotoUrl?: (photoId: number) => string
  carouselBottom?: string
}

export default function MobileMapTimeline({
  entries,
  mapEntries,
  trail,
  dark,
  readOnly,
  onEntryClick,
  onAddEntry,
  publicPhotoUrl,
  carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
}: Props) {
  const mapRef = useRef<JourneyMapHandle>(null)
  const carouselRef = useRef<HTMLDivElement>(null)
  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())
  // 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])

  // 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])

  // Initial map focus — delay to let Leaflet initialize and fitBounds
  useEffect(() => {
    if (entries.length > 0) {
      const timer = setTimeout(() => syncMapToCarousel(0), 500)
      return () => clearTimeout(timer)
    }
  }, [entries.length])

  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}
          height={9999}
          dark={dark}
          onMarkerClick={handleMarkerClick}
          fullScreen
        />
        {!readOnly && onAddEntry && (
          <div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
            <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}
        height={9999}
        dark={dark}
        activeMarkerId={activeEntryId}
        onMarkerClick={handleMarkerClick}
        fullScreen
        paddingBottom={200}
      />

      {/* Bottom carousel */}
      <div
        className="fixed left-0 right-0 z-40"
        style={{ touchAction: 'pan-x', bottom: carouselBottom }}
      >
        <div
          ref={carouselRef}
          className="flex gap-3 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' }}
            >
              <MobileEntryCard
                entry={entry}
                dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
                dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
                isActive={i === activeIndex}
                onClick={() => handleCardTap(entry, i)}
                publicPhotoUrl={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) + 168px)' }}
        >
          <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>
  )
}