mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
4974013995
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.
237 lines
7.7 KiB
TypeScript
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>
|
|
)
|
|
}
|