Files
TREK/client/src/components/Journey/MobileMapTimeline.tsx
T
Maurice 4974013995 fix journey bugs reported by roel-de-vries (#722-#736)
Mobile UI:
- #722 timeline carousel no longer cut off by BottomNav (uses --bottom-nav-h var)
- #723 scroll-snap-type relaxed to proximity so small swipes no longer skip entries
- #724 defensive padding-bottom fix in JourneySettingsDialog for iOS PWA
- #725 add back/settings buttons + journey title subtitle to mobile activity view
- #726 active entry re-centers after scroll settle; tap inactive card activates
  it (does not jump straight into editor)

Entry editor flow:
- #727 photo uploads queue locally until Save for existing entries too
  (previously fired upload immediately; Cancel silently kept the new photo)
- #728 Cancel/Close with unsaved changes now requires confirm (window.confirm)
- #729 linking a Gallery photo into an entry now copies the row (old MOVE
  behavior meant Remove-from-Entry also nuked the Gallery original)
- #731 addPhoto / addProviderPhoto / linkPhotoToEntry promote skeleton
  entries to concrete 'entry' type when content is added

Permissions:
- #732 updateJourney switched from canEdit to isOwner — editors can still
  edit entries and photos, just not the journey shell (title, cover, status)
- #733 Contributors list gains a per-row remove (X) control with confirm
- #734 my_role is computed server-side and returned with the journey; UI
  gates Settings/Add/Edit/Delete controls based on role
- #736 createOrUpdateJourneyShareLink + deleteJourneyShareLink now require
  isOwner (previously NO permission check at all — anyone authenticated
  could publish or unpublish a journey)

Immich upload (#730):
- migration 111: add users.immich_auto_upload (default 0)
- migration 112: seed provider_field for the toggle (idempotent, FK-safe)
- journey photo upload only mirrors to Immich when the user has opted in
- Settings UI gets a "Mirror journey photos to Immich on upload" checkbox

Test updates:
- JOURNEY-SVC-019 inverted to assert editor cannot update journey settings
- JOURNEY-SHARE-007 now passes userId (owner) to deleteJourneyShareLink
- FE-PAGE-JOURNEYDETAIL-148 inverted to assert photos stay pending until Save
- client/tests still green (2676/2676)

Also fixed en route: gallery entry title is now the literal 'Gallery' on the
wire (used to send the translated label, which broke server-side title === 'Gallery'
checks in non-English locales); confirm interpolation uses {username} single
braces matching the existing i18n runtime; Settings footer uses icon-only
delete/archive buttons on mobile so the row doesn't wrap.
2026-04-18 19:11:16 +02:00

237 lines
7.7 KiB
TypeScript

import { useRef, useState, useEffect, useCallback } 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'
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
}
export default function MobileMapTimeline({
entries,
mapEntries,
trail,
dark,
readOnly,
onEntryClick,
onAddEntry,
publicPhotoUrl,
}: Props) {
const mapRef = useRef<JourneyMapHandle>(null)
const carouselRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0)
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
const activeIndexRef = useRef(activeIndex)
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
// 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])
// Track scroll; debounce to re-center the active card when the user stops.
useEffect(() => {
const el = carouselRef.current
if (!el || entries.length === 0) return
let rafId: number | null = null
let settleTimer: number | null = null
const onScroll = () => {
if (rafId != null) return
rafId = requestAnimationFrame(() => {
pickNearestCard()
rafId = null
})
if (settleTimer != null) window.clearTimeout(settleTimer)
settleTimer = window.setTimeout(() => {
// 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 })
return () => {
el.removeEventListener('scroll', onScroll)
if (rafId != null) cancelAnimationFrame(rafId)
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 inset-0 z-10" style={{ top: 0, bottom: 0 }}>
<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 inset-0 z-10" style={{ top: 0, bottom: 0 }}>
{/* 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: 'calc(var(--bottom-nav-h, 84px) + 8px)' }}
>
<div
ref={carouselRef}
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
style={{
scrollSnapType: 'x proximity',
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}
index={i}
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>
)
}