Merge pull request #701 from mauriceboe/fix/mobile-overlay-bottom-nav

fix(mobile): account for bottom navbar in overlays and improve system notices UX
This commit is contained in:
Julien G.
2026-04-17 15:40:57 +02:00
committed by GitHub
18 changed files with 442 additions and 198 deletions
+2 -2
View File
@@ -331,8 +331,8 @@ export const journeyApi = {
// Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
+1 -1
View File
@@ -94,7 +94,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
onTouchEnd={e => {
@@ -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}
@@ -1268,7 +1268,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{/* ── Bag Modal (mobile + click) ── */}
{showBagModal && bagTrackingEnabled && (
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, overflowY: 'auto' }}
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
onClick={() => setShowBagModal(false)}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
onClick={e => e.stopPropagation()}>
@@ -79,6 +79,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
return (
<div
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
{/* Main area */}
@@ -1,4 +1,5 @@
import React, { useState, useEffect, 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';
@@ -70,159 +71,168 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
: DefaultIcon;
return (
<div className="flex flex-col relative">
<div className="flex flex-col relative" style={{ flex: '1 1 0', minHeight: '100%' }}>
{/* Dismiss X button — only on last page so users read all notices */}
{notice.dismissible && isLastPage && (
<button
onClick={onDismissAll}
className="absolute top-4 right-4 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
className="absolute top-4 right-4 z-10 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="Dismiss"
>
<X size={18} />
</button>
)}
{/* Hero image (not inline) */}
{notice.media && notice.media.placement !== 'inline' && (
<div
className="w-full overflow-hidden"
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
>
<img
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
alt={t(notice.media.altKey)}
className="w-full h-full object-cover"
fetchPriority="high"
decoding="async"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
)}
{/* Special warm header for Heart icon (thank-you notice) */}
{notice.icon === 'Heart' && !notice.media && (
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
<div className="relative flex items-center justify-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
<LucideIcon size={20} className="text-white" />
</div>
<div className="text-left">
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
</div>
</div>
</div>
)}
<div className={notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'}>
{/* Severity icon (when no hero and not Heart) */}
{!notice.media && notice.icon !== 'Heart' && (
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
<LucideIcon size={28} />
</div>
)}
{/* Title (not for Heart — rendered in gradient header) */}
{(notice.icon !== 'Heart' || notice.media) && (
<h2
id={titleId}
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
>
{title}
</h2>
)}
{/* Body — markdown (long body text uses left-aligned layout) */}
<div
id={bodyId}
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
>
<React.Suspense fallback={<p className="text-sm text-slate-500">{body}</p>}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
components={{
a: ({ href, children }) => (
<a
href={href}
className="text-indigo-600 dark:text-indigo-400 underline decoration-indigo-300 dark:decoration-indigo-700 hover:decoration-indigo-500 dark:hover:decoration-indigo-400 underline-offset-2 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
p: ({ children }) => {
// Signature line styling (e.g. "— Maurice")
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : '';
if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) {
return <p className="mt-4 mb-3 text-base font-semibold text-slate-800 dark:text-slate-200 italic">{children}</p>;
}
return <p className="mb-3 last:mb-0">{children}</p>;
},
hr: () => (
<div className="my-5 flex items-center gap-3">
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
<span className="text-slate-300 dark:text-slate-600 text-xs"></span>
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
</div>
),
strong: ({ children }) => <strong className="font-semibold text-slate-800 dark:text-slate-200">{children}</strong>,
ul: ({ children }) => <ul className="list-disc list-inside text-left">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside text-left">{children}</ol>,
}}
>
{body}
</ReactMarkdown>
</React.Suspense>
</div>
{/* Inline image */}
{notice.media?.placement === 'inline' && (
{/* Scrollable content — vertically centered when shorter than available space */}
<div className="flex flex-col justify-center" style={{ flex: '1 1 0' }}>
{/* Hero image (not inline) */}
{notice.media && notice.media.placement !== 'inline' && (
<div
className="w-full overflow-hidden rounded-lg mb-4 mx-auto"
className="w-full overflow-hidden"
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
>
<img
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
alt={t(notice.media.altKey)}
className="w-full h-full object-cover"
fetchPriority="high"
decoding="async"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
)}
{/* Highlights */}
{notice.highlights && notice.highlights.length > 0 && (
<ul className="mx-auto mb-4 space-y-2">
{notice.highlights.map((h, i) => {
const HIcon: React.ElementType | null = h.iconName
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
: null;
return (
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
{HIcon
? <HIcon size={16} className="text-blue-500 shrink-0" />
: <span className="text-blue-500 shrink-0"></span>
}
{t(h.labelKey)}
</li>
);
})}
</ul>
{/* Special warm header for Heart icon (thank-you notice) */}
{notice.icon === 'Heart' && !notice.media && (
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
<div className="relative flex items-center justify-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
<LucideIcon size={20} className="text-white" />
</div>
<div className="text-left">
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
</div>
</div>
</div>
)}
<div className={`${notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'} flex flex-col`}>
{/* Severity icon (when no hero and not Heart) */}
{!notice.media && notice.icon !== 'Heart' && (
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
<LucideIcon size={28} />
</div>
)}
{/* Title (not for Heart — rendered in gradient header) */}
{(notice.icon !== 'Heart' || notice.media) && (
<h2
id={titleId}
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
>
{title}
</h2>
)}
{/* Body — markdown */}
<div
id={bodyId}
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
>
<React.Suspense fallback={<p className="text-sm text-slate-500">{body}</p>}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
components={{
a: ({ href, children }) => (
<a
href={href}
className="text-indigo-600 dark:text-indigo-400 underline decoration-indigo-300 dark:decoration-indigo-700 hover:decoration-indigo-500 dark:hover:decoration-indigo-400 underline-offset-2 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
p: ({ children }) => {
// Signature line styling (e.g. "— Maurice")
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : '';
if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) {
return <p className="mt-4 mb-3 text-base font-semibold text-slate-800 dark:text-slate-200 italic">{children}</p>;
}
return <p className="mb-3 last:mb-0">{children}</p>;
},
hr: () => (
<div className="my-5 flex items-center gap-3">
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
<span className="text-slate-300 dark:text-slate-600 text-xs"></span>
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
</div>
),
strong: ({ children }) => <strong className="font-semibold text-slate-800 dark:text-slate-200">{children}</strong>,
ul: ({ children }) => <ul className="list-disc list-inside text-left">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside text-left">{children}</ol>,
}}
>
{body}
</ReactMarkdown>
</React.Suspense>
</div>
{/* Inline image */}
{notice.media?.placement === 'inline' && (
<div
className="w-full overflow-hidden rounded-lg mb-4 mx-auto"
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
>
<img
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
alt={t(notice.media.altKey)}
className="w-full h-full object-cover"
decoding="async"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
)}
{/* Highlights */}
{notice.highlights && notice.highlights.length > 0 && (
<ul className="mx-auto mb-4 space-y-2">
{notice.highlights.map((h, i) => {
const HIcon: React.ElementType | null = h.iconName
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
: null;
return (
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
{HIcon
? <HIcon size={16} className="text-blue-500 shrink-0" />
: <span className="text-blue-500 shrink-0"></span>
}
{t(h.labelKey)}
</li>
);
})}
</ul>
)}
</div>
</div>
{/* Sticky footer — pager + CTA, always anchored at the bottom of the slot */}
<div
className="sticky bottom-0 px-8 pt-4 flex flex-col gap-3 bg-white dark:bg-slate-900 border-t border-slate-100 dark:border-slate-800"
style={{ paddingBottom: 'calc(var(--bottom-nav-h) + 1rem)' }}
>
{/* Pager — dots, arrows, counter (only when multiple notices) */}
{total > 1 && (
<div className="flex flex-col items-center gap-1 mb-4">
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2">
<button
onClick={onPrev}
disabled={!canPage || currentPage === 0}
aria-label={t('system_notice.pager.prev')}
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={14} />
</button>
@@ -246,7 +256,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
onClick={onNext}
disabled={!canPage || currentPage === total - 1}
aria-label={t('system_notice.pager.next')}
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={14} />
</button>
@@ -261,17 +271,8 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
)}
{/* CTA + dismiss link */}
<div className="flex flex-col items-center gap-3 mt-2">
{!isLastPage && total > 1 ? (
/* Non-last page: "Next" button to advance through all notices */
<button
id={`notice-cta-${notice.id}`}
onClick={onNext}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors flex items-center justify-center gap-2"
>
{t('system_notice.pager.next')} <ChevronRight size={16} />
</button>
) : ctaLabel ? (
<div className="flex flex-col items-center gap-3">
{ctaLabel && isLastPage ? (
<button
id={`notice-cta-${notice.id}`}
onClick={onCTA}
@@ -279,10 +280,10 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
>
{ctaLabel}
</button>
) : (
) : (notice.dismissible || isLastPage) && (
<button
id={`notice-cta-${notice.id}`}
onClick={onDismissAll}
onClick={isLastPage ? onDismissAll : onNext}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
>
{t('common.ok')}
@@ -327,7 +328,13 @@ 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<number | null>(null);
const touchStartY = useRef<number | null>(null);
// 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified
const dragLockRef = useRef<'h' | 'v' | null>(null);
// Sheet scroll offset at the moment the touch began — used to suppress dismiss-drag
// when the user is scrolled into content and pans down to scroll back up.
const scrollTopAtTouchStart = useRef(0);
// Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
const noticeIdRef = useRef<string | null>(null);
noticeIdRef.current = notice?.id ?? null;
@@ -339,7 +346,15 @@ 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');
const contentWrapperRef = useRef<HTMLDivElement>(null);
// Mobile drag strip — wraps all 3 slots and is translated to reveal prev/current/next
const stripRef = useRef<HTMLDivElement>(null);
// The sheet element itself — animated on vertical drag-to-dismiss
const sheetRef = useRef<HTMLDivElement>(null);
const clipRef = useRef<HTMLDivElement>(null);
// Individual slot scroll containers (prev / center / next)
const prevSlotRef = useRef<HTMLDivElement>(null);
const contentWrapperRef = useRef<HTMLDivElement>(null); // center slot
const nextSlotRef = useRef<HTMLDivElement>(null);
// Mobile breakpoint
useEffect(() => {
@@ -455,6 +470,12 @@ export function ModalRenderer({ notices }: Props) {
return () => { document.body.style.overflow = ''; };
}, [visible, notice]);
// Reset center slot scroll to top on navigation (keyboard / pager buttons).
useEffect(() => {
if (!isMobile) return;
contentWrapperRef.current?.scrollTo({ top: 0 });
}, [idx, isMobile]);
function announceIndex(newIdx: number, total: number) {
setPageAnnouncement(
t('system_notice.pager.position')
@@ -498,6 +519,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.
@@ -576,6 +608,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 (
<div className="fixed inset-0 z-50" role="presentation">
{/* Screen-reader page announcements */}
@@ -583,30 +647,150 @@ export function ModalRenderer({ notices }: Props) {
{/* Backdrop */}
<div
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
onClick={notice.dismissible ? handleDismiss : undefined}
onClick={notice.dismissible ? animatedDismissAll : undefined}
/>
{/* Bottom sheet */}
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
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-all ${dur} ${ease} ${mobileMotion}`}
onTouchStart={e => { 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 h-[85dvh] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
style={{ touchAction: 'pan-y' }}
onTouchStart={e => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
dragLockRef.current = null;
scrollTopAtTouchStart.current = contentWrapperRef.current?.scrollTop ?? 0;
}}
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';
// Reset adjacent slots to top before they slide into view.
if (dragLockRef.current === 'h') {
prevSlotRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
}
}
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) {
// Only intercept downward drag for dismiss when the sheet is scrolled to the top.
// If scrolled into content, let native pan-y scroll it back up.
if (scrollTopAtTouchStart.current > 0) return;
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);
});
// Reset all slot scrolls so the new center starts at top.
prevSlotRef.current?.scrollTo({ top: 0 });
contentWrapperRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
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 (only when at scroll top)
if (lock === 'v' && startY !== null && scrollTopAtTouchStart.current === 0) {
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 */}
<div className="pt-3 pb-1 flex justify-center">
{/* Drag handle — fixed, does not scroll */}
<div className="pt-3 pb-1 flex justify-center shrink-0">
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
</div>
<div ref={contentWrapperRef}>
<NoticeContent {...contentProps} />
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
<div style={{ flex: '1 1 0', minHeight: 0, overflow: 'hidden', width: '100%' }}>
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
<div
ref={stripRef}
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
>
<div ref={prevSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{prevNotice && <NoticeContent {...buildSlotProps(prevNotice, idx - 1)} />}
</div>
<div ref={contentWrapperRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<NoticeContent {...contentProps} />
</div>
<div ref={nextSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{nextNotice && <NoticeContent {...buildSlotProps(nextNotice, idx + 1)} />}
</div>
</div>
</div>
</div>
</div>
+2 -2
View File
@@ -394,7 +394,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
)}
{selectedItem && !isAddingNew && isMobile && (
<div onClick={e => { 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)' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { 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' } } }}>
<DetailPane
@@ -419,7 +419,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
)}
{isAddingNew && !selectedItem && isMobile && (
<div onClick={e => { 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)' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { 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' } } }}>
<NewTaskPane
+1 -1
View File
@@ -51,7 +51,7 @@ export default function Modal({
return (
<div
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }}
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
+5 -5
View File
@@ -1021,7 +1021,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
trips={trips}
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
onClose={() => setShowPicker(false)}
onAdd={async (assetIds, entryId) => {
onAdd={async (assetIds, entryId, passphrase) => {
let targetId = entryId
if (!targetId) {
try {
@@ -1035,7 +1035,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
}
let added = 0
try {
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds)
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds, undefined, passphrase)
added = result.added || 0
} catch {}
if (added > 0) {
@@ -1511,7 +1511,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
trips: JourneyTrip[]
existingAssetIds: Set<string>
onClose: () => void
onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
onAdd: (assetIds: string[], entryId: number | null, passphrase?: string) => Promise<void>
}) {
const { t } = useTranslation()
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
@@ -1884,7 +1884,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{t('common.cancel')}
</button>
<button
onClick={() => onAdd([...selected], targetEntryId)}
onClick={() => onAdd([...selected], targetEntryId, selectedAlbumPassphrase)}
disabled={selected.size === 0}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
@@ -2091,7 +2091,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
return (
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center sm:justify-center sm:p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]">
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
+1 -1
View File
@@ -279,7 +279,7 @@ export default function JourneyPage() {
{/* Create Modal */}
{showCreate && (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
{/* Header */}
<div className="px-7 pt-6 pb-5 border-b border-zinc-200 dark:border-zinc-700">
+1 -1
View File
@@ -838,7 +838,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
{selectedPlace && isMobile && ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }} onClick={() => setSelectedPlaceId(null)}>
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', paddingBottom: 'var(--bottom-nav-h)' }} onClick={() => setSelectedPlaceId(null)}>
<div style={{ width: '100%', maxHeight: '85vh' }} onClick={e => e.stopPropagation()}>
<PlaceInspector
place={selectedPlace}
+4 -3
View File
@@ -115,13 +115,14 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { provider, asset_id, asset_ids, caption } = req.body || {};
const { provider, asset_id, asset_ids, caption, passphrase } = req.body || {};
const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
// Batch mode: { provider, asset_ids: string[] }
if (Array.isArray(asset_ids) && provider) {
const added: any[] = [];
for (const id of asset_ids) {
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption);
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption, pp);
if (photo) added.push(photo);
}
return res.status(201).json({ photos: added, added: added.length });
@@ -129,7 +130,7 @@ router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, re
// Single mode (backward compat)
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption, pp);
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
res.status(201).json(photo);
});
+2 -2
View File
@@ -628,12 +628,12 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
}
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId);
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
// skip if already added
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
+14 -21
View File
@@ -637,27 +637,20 @@ export async function streamSynologyAsset(
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
const resolvedSize = size || 'sm';
const params = kind === 'thumbnail'
? new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
version: '2',
mode: 'download',
id: parsedId.id,
type: 'unit',
size: resolvedSize,
cache_key: parsedId.cacheKey,
_sid: sid.data,
})
: new URLSearchParams({
api: 'SYNO.Foto.Download',
method: 'download',
version: '2',
cache_key: parsedId.cacheKey,
unit_id: `[${parsedId.id}]`,
_sid: sid.data,
});
// Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files
// (original uses xl size to get a full-resolution JPEG-compatible render)
const resolvedSize = kind === 'original' ? 'xl' : (size || 'sm');
const params = new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
version: '2',
mode: 'download',
id: parsedId.id,
type: 'unit',
size: resolvedSize,
cache_key: parsedId.cacheKey,
_sid: sid.data,
});
if (passphrase) params.append('passphrase', passphrase);
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
+10 -1
View File
@@ -1,10 +1,19 @@
import { createRequire } from 'module';
import semver from 'semver';
import { db } from '../db/database.js';
import { SYSTEM_NOTICES } from './registry.js';
import { evaluate } from './conditions.js';
import type { SystemNoticeDTO } from './types.js';
function getCurrentAppVersion(): string {
return process.env.APP_VERSION || '0.0.0';
const fromEnv = semver.valid(process.env.APP_VERSION ?? '');
if (fromEnv) return fromEnv;
try {
const pkg = require('../../package.json') as { version?: string };
return semver.valid(pkg.version ?? '') ?? '0.0.0';
} catch {
return '0.0.0';
}
}
function severityWeight(s: string): number {
+44
View File
@@ -936,6 +936,50 @@ describe('Share link update', () => {
});
// ─────────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// Provider photos passphrase (JOURNEY-INT-046, 047)
// ─────────────────────────────────────────────────────────────────────────────
describe('Provider photos — passphrase persistence', () => {
it('JOURNEY-INT-046 — single mode with passphrase persists encrypted passphrase on trek_photos', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-01' });
const res = await request(app)
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
.set('Cookie', authCookie(user.id))
.send({ provider: 'synologyphotos', asset_id: 'shared-asset-1', passphrase: 'pp-test' });
expect(res.status).toBe(201);
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
.get('synologyphotos', 'shared-asset-1', user.id) as { passphrase: string | null } | undefined;
expect(row?.passphrase).not.toBeNull();
expect(typeof row?.passphrase).toBe('string');
});
it('JOURNEY-INT-047 — batch mode with passphrase persists encrypted passphrase on all trek_photos rows', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-02' });
const res = await request(app)
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
.set('Cookie', authCookie(user.id))
.send({ provider: 'synologyphotos', asset_ids: ['batch-asset-1', 'batch-asset-2'], passphrase: 'pp-batch' });
expect(res.status).toBe(201);
expect(res.body.added).toBe(2);
for (const assetId of ['batch-asset-1', 'batch-asset-2']) {
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
.get('synologyphotos', assetId, user.id) as { passphrase: string | null } | undefined;
expect(row?.passphrase).not.toBeNull();
}
});
});
// Photo upload without files (JOURNEY-INT-045)
// ─────────────────────────────────────────────────────────────────────────────
@@ -166,16 +166,6 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
});
}
// Original download
if (apiName === 'SYNO.Foto.Download') {
const imageBytes = Buffer.from('fake-synology-original');
return Promise.resolve({
ok: true, status: 200,
headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null },
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
});
}
return Promise.reject(new Error(`Unexpected safeFetch call to Synology: ${u}, api=${apiName}`));
}
@@ -1412,3 +1412,24 @@ describe('Edge cases', () => {
expect(filledRow.source_place_id).toBeNull();
});
});
// -- Passphrase on addProviderPhoto -------------------------------------------
describe('addProviderPhoto — passphrase', () => {
it('JOURNEY-SVC-088: addProviderPhoto with passphrase stores encrypted value on trek_photos', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-15' });
const photo = addProviderPhoto(entry.id, user.id, 'synologyphotos', 'pp-asset-1', undefined, 'secret-pp');
expect(photo).not.toBeNull();
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
.get('synologyphotos', 'pp-asset-1', user.id) as { passphrase: string | null } | undefined;
expect(row?.passphrase).not.toBeNull();
expect(typeof row?.passphrase).toBe('string');
// stored value must be encrypted (not plaintext)
expect(row?.passphrase).not.toBe('secret-pp');
});
});