mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
fef12b0e8b
- Add paddingBottom: var(--bottom-nav-h) to all mobile overlays that were clipping content behind the bottom navbar: EntryEditor, SystemNoticeModal, JourneyPage create modal, TodoListPanel sheets, TripPlannerPage PlaceInspector, PackingListPanel bag modal, both PhotoLightboxes, FileManager viewer, and shared Modal primitive - Replace single-notice mobile bottom sheet with a 3-slot horizontal strip so adjacent notices are physically present during drag - Add live-follow swipe left/right to navigate between notices with spring-back when under threshold and flushSync to eliminate blink on commit - Add live-follow swipe down to dismiss all notices with spring-back; backdrop tap also triggers the slide-down animation - Normalize notice height with useLayoutEffect minHeight on strip and align-items: stretch so all slots are always the tallest notice height - Pin CTA button at consistent Y across notices via flex-1 + mt-auto; always render invisible Not now placeholder to equalise CTA section height - Move pager dots/counter below CTA buttons
151 lines
5.1 KiB
TypeScript
151 lines
5.1 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
|
|
|
|
interface LightboxPhoto {
|
|
id: string
|
|
src: string
|
|
caption?: string | null
|
|
provider?: string
|
|
asset_id?: string | null
|
|
owner_id?: number | null
|
|
}
|
|
|
|
interface Props {
|
|
photos: LightboxPhoto[]
|
|
startIndex?: number
|
|
onClose: () => void
|
|
}
|
|
|
|
export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) {
|
|
const [idx, setIdx] = useState(startIndex)
|
|
const touchStart = useRef<{ x: number; y: number } | null>(null)
|
|
|
|
const photo = photos[idx]
|
|
const hasPrev = idx > 0
|
|
const hasNext = idx < photos.length - 1
|
|
|
|
const prev = useCallback(() => { if (hasPrev) setIdx(i => i - 1) }, [hasPrev])
|
|
const next = useCallback(() => { if (hasNext) setIdx(i => i + 1) }, [hasNext])
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose()
|
|
if (e.key === 'ArrowLeft') prev()
|
|
if (e.key === 'ArrowRight') next()
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
}, [prev, next, onClose])
|
|
|
|
const onTouchStart = (e: React.TouchEvent) => {
|
|
const t = e.touches[0]
|
|
touchStart.current = { x: t.clientX, y: t.clientY }
|
|
}
|
|
|
|
const onTouchEnd = (e: React.TouchEvent) => {
|
|
if (!touchStart.current) return
|
|
const t = e.changedTouches[0]
|
|
const dx = t.clientX - touchStart.current.x
|
|
const dy = t.clientY - touchStart.current.y
|
|
|
|
// swipe down to close
|
|
if (dy > 80 && Math.abs(dx) < 60) {
|
|
onClose()
|
|
return
|
|
}
|
|
// horizontal swipe
|
|
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
|
|
if (dx < 0) next()
|
|
else prev()
|
|
}
|
|
touchStart.current = null
|
|
}
|
|
|
|
if (!photo) return null
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
position: 'fixed', inset: 0, zIndex: 500,
|
|
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
|
|
display: 'flex', flexDirection: 'column',
|
|
paddingBottom: 'var(--bottom-nav-h)',
|
|
}}
|
|
onTouchStart={onTouchStart}
|
|
onTouchEnd={onTouchEnd}
|
|
>
|
|
{/* Photo area — centered with nav overlays */}
|
|
<div
|
|
className="group/lightbox"
|
|
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }}
|
|
>
|
|
{/* Top bar */}
|
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}>
|
|
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: 500 }}>
|
|
{idx + 1} / {photos.length}
|
|
</span>
|
|
<button onClick={onClose} style={{
|
|
background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '50%',
|
|
width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
color: '#fff', cursor: 'pointer',
|
|
}}>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
|
|
{hasPrev && (
|
|
<button onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
|
|
position: 'absolute', left: 16, zIndex: 5,
|
|
width: 44, height: 44, borderRadius: '50%',
|
|
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
alignItems: 'center', justifyContent: 'center',
|
|
color: '#fff', cursor: 'pointer',
|
|
}}>
|
|
<ChevronLeft size={22} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Photo */}
|
|
<img
|
|
key={photo.id}
|
|
src={photo.src}
|
|
alt={photo.caption || ''}
|
|
style={{
|
|
maxWidth: '92vw', maxHeight: '92vh',
|
|
objectFit: 'contain', borderRadius: 4,
|
|
animation: 'fadeIn 0.15s ease',
|
|
}}
|
|
/>
|
|
|
|
{/* Next button */}
|
|
{hasNext && (
|
|
<button onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
|
|
position: 'absolute', right: 16, zIndex: 5,
|
|
width: 44, height: 44, borderRadius: '50%',
|
|
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
alignItems: 'center', justifyContent: 'center',
|
|
color: '#fff', cursor: 'pointer',
|
|
}}>
|
|
<ChevronRight size={22} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Caption — bottom center overlay */}
|
|
{photo.caption && (
|
|
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
|
|
<p style={{
|
|
fontSize: 14, fontStyle: 'italic',
|
|
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
|
|
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
|
|
padding: '6px 14px', borderRadius: 10,
|
|
}}>{photo.caption}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|