diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 6092806a..b70a52e7 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -94,7 +94,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) { return (
setTouchStart(e.touches[0].clientX)} onTouchEnd={e => { diff --git a/client/src/components/Journey/PhotoLightbox.tsx b/client/src/components/Journey/PhotoLightbox.tsx index e3096ee1..f8e799a2 100644 --- a/client/src/components/Journey/PhotoLightbox.tsx +++ b/client/src/components/Journey/PhotoLightbox.tsx @@ -69,6 +69,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props 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} diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index d5745476..14cbced9 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -1268,7 +1268,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp {/* ── Bag Modal (mobile + click) ── */} {showBagModal && bagTrackingEnabled && ( -
setShowBagModal(false)}>
e.stopPropagation()}> diff --git a/client/src/components/Photos/PhotoLightbox.tsx b/client/src/components/Photos/PhotoLightbox.tsx index ba6a5738..bb90170a 100644 --- a/client/src/components/Photos/PhotoLightbox.tsx +++ b/client/src/components/Photos/PhotoLightbox.tsx @@ -79,6 +79,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet return (
{/* Main area */} diff --git a/client/src/components/SystemNotices/SystemNoticeModal.tsx b/client/src/components/SystemNotices/SystemNoticeModal.tsx index 10d0a13f..6b9e23a2 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useLayoutEffect, useRef } from 'react'; +import { flushSync } from 'react-dom'; import { useNavigate } from 'react-router-dom'; import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; @@ -69,7 +70,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, : DefaultIcon; return ( -
+
{/* Dismiss X button */} {notice.dismissible && ( + ) : ( + + )} + +
+ {/* Pager — dots, arrows, counter (only when multiple notices) */} {total > 1 && ( -
+
)} - - {/* CTA + dismiss link */} -
- {ctaLabel ? ( - - ) : ( - - )} - {notice.dismissible && ctaLabel && ( - - )} -
); @@ -283,7 +282,10 @@ export function ModalRenderer({ notices }: Props) { // Non-dismissible notices lock the pager so users must act before advancing. const canPage = notice?.dismissible !== false; + const touchStartX = useRef(null); const touchStartY = useRef(null); + // 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified + const dragLockRef = useRef<'h' | 'v' | null>(null); // Keep a ref to the current notice id so dismiss/CTA handlers see the latest value const noticeIdRef = useRef(null); noticeIdRef.current = notice?.id ?? null; @@ -295,6 +297,13 @@ export function ModalRenderer({ notices }: Props) { // contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly. const isPageNavRef = useRef(false); const slideDirRef = useRef<'left' | 'right'>('right'); + // Mobile drag strip — wraps all 3 slots and is translated to reveal prev/current/next + const stripRef = useRef(null); + // The sheet element itself — animated on vertical drag-to-dismiss + const sheetRef = useRef(null); + // Clip container ref + cached max height — used to pin sheet height to tallest notice + const clipRef = useRef(null); + const maxClipHeightRef = useRef(0); const contentWrapperRef = useRef(null); // Mobile breakpoint @@ -410,6 +419,19 @@ export function ModalRenderer({ notices }: Props) { return () => { document.body.style.overflow = ''; }; }, [visible, notice]); + // Pin the strip to the tallest notice height seen so far. + // Setting minHeight on the strip (not the clip) forces align-items:stretch to + // make every slot exactly that tall, so mt-auto always bottoms out at the same Y. + useLayoutEffect(() => { + if (!isMobile) return; + const el = stripRef.current; + if (!el) return; + el.style.minHeight = ''; + const h = el.scrollHeight; + if (h > maxClipHeightRef.current) maxClipHeightRef.current = h; + el.style.minHeight = `${maxClipHeightRef.current}px`; + }); + function announceIndex(newIdx: number, total: number) { setPageAnnouncement( t('system_notice.pager.position') @@ -453,6 +475,17 @@ export function ModalRenderer({ notices }: Props) { } } + function animatedDismissAll() { + const sheet = sheetRef.current; + if (!sheet || prefersReducedMotion) { handleDismissAll(); return; } + sheet.style.transition = 'transform 300ms ease-out'; + sheet.style.transform = 'translateY(110%)'; + sheet.addEventListener('transitionend', function onDone() { + sheet.removeEventListener('transitionend', onDone); + handleDismissAll(); + }, { once: true }); + } + // Sets up the content wrapper's start transform SYNCHRONOUSLY (before React // re-renders with the new notice), then flags the grace-delay effect to slide // rather than hide+show. @@ -531,6 +564,38 @@ export function ModalRenderer({ notices }: Props) { ? (visible ? 'opacity-100' : 'opacity-0') : (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full'); + // Build ContentProps for an adjacent slot so NoticeContent renders correctly + function buildSlotProps(n: SystemNoticeDTO, slotIdx: number): ContentProps { + const slotRawBody = t(n.bodyKey); + const slotBody = n.bodyParams + ? Object.entries(n.bodyParams).reduce( + (s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v), + slotRawBody + ) + : slotRawBody; + return { + notice: n, + title: t(n.titleKey), + body: slotBody, + ctaLabel: n.cta ? t(n.cta.labelKey) : null, + titleId: `notice-title-${n.id}`, + bodyId: `notice-body-${n.id}`, + isDark, + onDismiss: handleDismiss, + onDismissAll: handleDismissAll, + onCTA: handleCTA, + total: notices.length, + currentPage: slotIdx, + canPage, + onPrev: handlePrev, + onNext: handleNext, + onGoto: handleGoto, + }; + } + + const prevNotice = notices[idx - 1] ?? null; + const nextNotice = notices[idx + 1] ?? null; + return (
{/* Screen-reader page announcements */} @@ -538,30 +603,136 @@ export function ModalRenderer({ notices }: Props) { {/* Backdrop */}
{/* Bottom sheet */}
{ touchStartY.current = e.touches[0].clientY; }} - onTouchEnd={e => { - if (touchStartY.current !== null && notice.dismissible) { - const delta = e.changedTouches[0].clientY - touchStartY.current; - if (delta > 80) handleDismiss(); + className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden max-h-[85dvh] overflow-y-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`} + style={{ paddingBottom: 'var(--bottom-nav-h)', touchAction: 'pan-y' }} + onTouchStart={e => { + touchStartX.current = e.touches[0].clientX; + touchStartY.current = e.touches[0].clientY; + dragLockRef.current = null; + }} + onTouchMove={e => { + if (prefersReducedMotion) return; + const startX = touchStartX.current; + const startY = touchStartY.current; + if (startX === null || startY === null) return; + const dx = e.touches[0].clientX - startX; + const dy = e.touches[0].clientY - startY; + // Classify gesture direction on first significant movement + if (!dragLockRef.current) { + if (Math.abs(dx) > 8 || Math.abs(dy) > 8) + dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v'; + return; } + if (dragLockRef.current === 'h') { + const strip = stripRef.current; + if (!strip) return; + strip.style.transition = 'none'; + // Strip base = -33.333% (center slot visible); dx offsets from there + strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`; + } else if (dragLockRef.current === 'v' && notice.dismissible) { + const sheet = sheetRef.current; + if (!sheet || dy <= 0) return; + sheet.style.transition = 'none'; + sheet.style.transform = `translateY(${dy}px)`; + } + }} + onTouchEnd={e => { + const startX = touchStartX.current; + const startY = touchStartY.current; + touchStartX.current = null; touchStartY.current = null; + const lock = dragLockRef.current; + dragLockRef.current = null; + + if (lock === 'h') { + if (startX === null) return; + const deltaX = e.changedTouches[0].clientX - startX; + const strip = stripRef.current; + if (!strip) return; + + const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50; + const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50; + const canGoNext = canPage && idx < notices.length - 1; + const canGoPrev = canPage && idx > 0; + + if ((goNext && canGoNext) || (goPrev && canGoPrev)) { + // Animate strip to the adjacent slot (-66.666% = next, 0% = prev) + strip.style.transition = 'transform 200ms ease-out'; + strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)'; + strip.addEventListener('transitionend', function onDone() { + strip.removeEventListener('transitionend', onDone); + strip.style.transition = 'none'; + // Render new content into the center slot BEFORE moving the strip, + // so the browser never paints old content at the center position. + const newIdx = goNext ? idx + 1 : idx - 1; + flushSync(() => { + isPageNavRef.current = true; + setIdx(newIdx); + announceIndex(newIdx, notices.length); + }); + strip.style.transform = 'translateX(-33.333%)'; + }, { once: true }); + } else { + // Spring back to center + strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)'; + strip.style.transform = 'translateX(-33.333%)'; + strip.addEventListener('transitionend', function onSnap() { + strip.removeEventListener('transitionend', onSnap); + strip.style.transition = ''; + strip.style.transform = 'translateX(-33.333%)'; + }, { once: true }); + } + return; + } + + // Vertical drag — animated dismiss or spring back + if (lock === 'v' && startY !== null) { + const deltaY = e.changedTouches[0].clientY - startY; + const sheet = sheetRef.current; + if (deltaY > 80 && notice.dismissible) { + animatedDismissAll(); + } else if (sheet && deltaY > 0) { + sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)'; + sheet.style.transform = 'translateY(0)'; + sheet.addEventListener('transitionend', function onSnap() { + sheet.removeEventListener('transitionend', onSnap); + sheet.style.transition = ''; + sheet.style.transform = ''; + }, { once: true }); + } + } }} > {/* Drag handle */}
-
- + {/* Clip container — hides the adjacent slots outside the sheet width */} +
+ {/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */} +
+
+ {prevNotice && } +
+
+ +
+
+ {nextNotice && } +
+
diff --git a/client/src/components/Todo/TodoListPanel.tsx b/client/src/components/Todo/TodoListPanel.tsx index 9f8a396f..2e7155ce 100644 --- a/client/src/components/Todo/TodoListPanel.tsx +++ b/client/src/components/Todo/TodoListPanel.tsx @@ -394,7 +394,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items )} {selectedItem && !isAddingNew && isMobile && (
{ if (e.target === e.currentTarget) setSelectedId(null) }} - style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}> + style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}> { if (e.target === e.currentTarget) setIsAddingNew(false) }} - style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}> + style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}> { mouseDownTarget.current = e.target }} onClick={e => { if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose() diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 1db6de3c..721bdd6b 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -2030,7 +2030,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa return (
-
+

{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}

diff --git a/client/src/pages/JourneyPage.tsx b/client/src/pages/JourneyPage.tsx index 564cdddd..1c094eb6 100644 --- a/client/src/pages/JourneyPage.tsx +++ b/client/src/pages/JourneyPage.tsx @@ -279,7 +279,7 @@ export default function JourneyPage() { {/* Create Modal */} {showCreate && (
-
+
{/* Header */}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 0f152392..5c24201d 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -838,7 +838,7 @@ export default function TripPlannerPage(): React.ReactElement | null { )} {selectedPlace && isMobile && ReactDOM.createPortal( -
setSelectedPlaceId(null)}> +
setSelectedPlaceId(null)}>
e.stopPropagation()}>