From df075630fb5b5cf5e9605252474c829f45656283 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 16 Apr 2026 22:25:03 +0200 Subject: [PATCH 1/3] feat(system-notices): add personal thank-you notice for v3.0.0 Personal note from the creator shown as the first page in the 3.0 upgrade modal. Includes community links (Discord, Ko-fi) and a special shout-out to jubnl. Modal UX improved: users must click through all pages before dismissing, wider layout, enhanced markdown rendering with styled links, signature, and HR separator. i18n coverage across all 15 languages. --- .../SystemNotices/SystemNoticeModal.tsx | 95 ++++++++++++++----- client/src/i18n/translations/ar.ts | 4 + client/src/i18n/translations/br.ts | 4 + client/src/i18n/translations/cs.ts | 4 + client/src/i18n/translations/de.ts | 4 + client/src/i18n/translations/en.ts | 4 + client/src/i18n/translations/es.ts | 4 + client/src/i18n/translations/fr.ts | 4 + client/src/i18n/translations/hu.ts | 4 + client/src/i18n/translations/id.ts | 4 + client/src/i18n/translations/it.ts | 4 + client/src/i18n/translations/nl.ts | 4 + client/src/i18n/translations/pl.ts | 4 + client/src/i18n/translations/ru.ts | 4 + client/src/i18n/translations/zh.ts | 4 + client/src/i18n/translations/zhTw.ts | 4 + server/src/systemNotices/registry.ts | 14 +++ 17 files changed, 144 insertions(+), 25 deletions(-) diff --git a/client/src/components/SystemNotices/SystemNoticeModal.tsx b/client/src/components/SystemNotices/SystemNoticeModal.tsx index 10d0a13f..e010bc24 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.tsx @@ -62,6 +62,7 @@ interface ContentProps { function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) { const { t } = useTranslation(); + const isLastPage = total <= 1 || currentPage === total - 1; const DefaultIcon = SEVERITY_ICONS[notice.severity] ?? Info; const LucideIcon: React.ElementType = notice.icon @@ -70,8 +71,8 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, return (
- {/* Dismiss X button */} - {notice.dismissible && ( + {/* Dismiss X button — only on last page so users read all notices */} + {notice.dismissible && isLastPage && ( + ) : ctaLabel ? ( + ) +} diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx new file mode 100644 index 00000000..0062d96e --- /dev/null +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -0,0 +1,218 @@ +import { useState } from 'react' +import { + X, Pencil, Trash2, MapPin, Clock, Camera, + Laugh, Smile, Meh, Frown, + Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, + ThumbsUp, ThumbsDown, ChevronDown, +} from 'lucide-react' +import JournalBody from './JournalBody' +import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' + +const MOOD_CONFIG: Record = { + amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' }, + good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, + neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' }, + rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' }, +} + +const WEATHER_CONFIG: Record = { + sunny: { icon: Sun, label: 'Sunny' }, + partly: { icon: CloudSun, label: 'Partly cloudy' }, + cloudy: { icon: Cloud, label: 'Cloudy' }, + rainy: { icon: CloudRain, label: 'Rainy' }, + stormy: { icon: CloudLightning, label: 'Stormy' }, + cold: { icon: Snowflake, label: 'Cold' }, +} + +function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string { + return `/api/photos/${p.photo_id}/${size}` +} + +interface Props { + entry: JourneyEntry + onClose: () => void + onEdit: () => void + onDelete: () => void + onPhotoClick: (photos: JourneyPhoto[], index: number) => void +} + +export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPhotoClick }: Props) { + const photos = entry.photos || [] + const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null + const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null + const prosArr = entry.pros_cons?.pros ?? [] + const consArr = entry.pros_cons?.cons ?? [] + const hasProscons = prosArr.length > 0 || consArr.length > 0 + + const date = new Date(entry.entry_date + 'T00:00:00') + const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' }) + + return ( +
+ {/* Top bar */} +
+ +
+ + +
+
+ + {/* Scrollable content */} +
+ + {/* Hero photo(s) */} + {photos.length > 0 && ( +
+ onPhotoClick(photos, 0)} + /> + {photos.length > 1 && ( +
+ + {photos.length} photos +
+ )} + {/* Photo strip for multiple photos */} + {photos.length > 1 && ( +
+ {photos.map((p, i) => ( + onPhotoClick(photos, i)} + /> + ))} +
+ )} +
+ )} + + {/* Content */} +
+ + {/* Date + time + location header */} +
+ {dateStr} + {entry.entry_time && ( + + + {entry.entry_time.slice(0, 5)} + + )} +
+ + {entry.location_name && ( +
+ + + {entry.location_name} + +
+ )} + + {/* Title */} + {entry.title && ( +

+ {entry.title} +

+ )} + + {/* Mood + Weather chips */} + {(mood || weather) && ( +
+ {mood && ( + + + {mood.label} + + )} + {weather && ( + + + {weather.label} + + )} +
+ )} + + {/* Story */} + {entry.story && ( +
+ +
+ )} + + {/* Tags */} + {entry.tags && entry.tags.length > 0 && ( +
+ {entry.tags.map((tag, i) => ( + + {tag} + + ))} +
+ )} + + {/* Pros & Cons */} + {hasProscons && ( +
+ {prosArr.length > 0 && ( +
+
+ Pros +
+
    + {prosArr.map((p, i) => ( +
  • + + {p} +
  • + ))} +
+
+ )} + {prosArr.length > 0 && consArr.length > 0 && ( +
+ )} + {consArr.length > 0 && ( +
+
+ Cons +
+
    + {consArr.map((c, i) => ( +
  • + {c} +
  • + ))} +
+
+ )} +
+ )} +
+
+
+ ) +} diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx new file mode 100644 index 00000000..a0f64df4 --- /dev/null +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -0,0 +1,194 @@ +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(null) + const carouselRef = useRef(null) + const [activeIndex, setActiveIndex] = useState(0) + const cardRefs = useRef>(new Map()) + + // 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]) + + // IntersectionObserver for instant snap detection + useEffect(() => { + const el = carouselRef.current + if (!el || entries.length === 0) return + + const observer = new IntersectionObserver( + (observed) => { + for (const o of observed) { + if (o.isIntersecting) { + const idx = Number(o.target.getAttribute('data-idx')) + if (!isNaN(idx)) { + setActiveIndex(idx) + syncMapToCarousel(idx) + } + } + } + }, + { root: el, threshold: 0.6 }, + ) + + cardRefs.current.forEach(node => observer.observe(node)) + return () => observer.disconnect() + }, [entries.length, syncMapToCarousel]) + + // 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) + + const el = carouselRef.current + if (!el) return + const cardWidth = 272 + el.scrollTo({ left: idx * cardWidth, behavior: 'smooth' }) + }, [entries]) + + // 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 ( +
+ + {!readOnly && onAddEntry && ( +
+ +
+ )} +
+ ) + } + + return ( +
+ {/* Full-screen map */} + + + {/* Bottom carousel */} +
+
+ {entries.map((entry: any, i: number) => ( +
{ if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }} + style={{ scrollSnapAlign: 'center' }} + > + onEntryClick(entry)} + publicPhotoUrl={publicPhotoUrl} + /> +
+ ))} +
+
+ + {/* FAB: add entry — top right */} + {!readOnly && onAddEntry && ( +
+ +
+ )} +
+ ) +} diff --git a/client/src/hooks/useIsMobile.ts b/client/src/hooks/useIsMobile.ts new file mode 100644 index 00000000..82504f3c --- /dev/null +++ b/client/src/hooks/useIsMobile.ts @@ -0,0 +1,18 @@ +import { useState, useEffect } from 'react' + +/** Returns true when the viewport is below the lg breakpoint (1024px). */ +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState( + () => typeof window !== 'undefined' && window.innerWidth < 1024, + ) + + useEffect(() => { + const mq = window.matchMedia('(max-width: 1023px)') + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches) + setIsMobile(mq.matches) + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, []) + + return isMobile +} diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index cd70f881..e0fb72c7 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1958,6 +1958,7 @@ const en: Record = { 'journey.detail.noEntriesHint': 'Add a trip to get started with skeleton entries', 'journey.detail.noPhotos': 'No photos yet', 'journey.detail.noPhotosHint': 'Upload photos to entries or browse your Immich/Synology library', + 'journey.detail.journeyTab': 'Journey', 'journey.detail.journeyStats': 'Journey Stats', 'journey.detail.syncedTrips': 'Synced Trips', 'journey.detail.noTripsLinked': 'No trips linked yet', diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 028b0ecc..295373f6 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react' +import { createPortal } from 'react-dom' import { useParams, useNavigate } from 'react-router-dom' import { useJourneyStore } from '../store/journeyStore' import { useAuthStore } from '../store/authStore' @@ -20,6 +21,9 @@ import { Laugh, Smile, Meh, Annoyed, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff, } from 'lucide-react' +import MobileMapTimeline from '../components/Journey/MobileMapTimeline' +import MobileEntryView from '../components/Journey/MobileEntryView' +import { useIsMobile } from '../hooks/useIsMobile' import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore' const GRADIENTS = [ @@ -84,7 +88,9 @@ export default function JourneyDetailPage() { const fullMapRef = useRef(null) const [activeLocationId, setActiveLocationId] = useState(null) + const isMobile = useIsMobile() const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline') + const [viewingEntry, setViewingEntry] = useState(null) const [editingEntry, setEditingEntry] = useState(null) const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null) const [deleteTarget, setDeleteTarget] = useState(null) @@ -202,10 +208,61 @@ export default function JourneyDetailPage() { const dayGroups = groupByDate(timelineEntries) const sortedDates = [...dayGroups.keys()].sort() + const showMobileCombined = isMobile && view === 'timeline' + return (
-
+ + {/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */} + {showMobileCombined && ( + setViewingEntry(entry)} + onAddEntry={() => { + const today = new Date().toISOString().split('T')[0] + setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry) + }} + /> + )} + + {/* Fullscreen entry view (mobile) — portal to body for iOS stacking */} + {viewingEntry && createPortal( + setViewingEntry(null)} + onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }} + onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }} + onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} + />, + document.body + )} + + {/* Floating tab toggle on mobile combined view */} + {showMobileCombined && ( +
+
+ + +
+
+ )} + +
{/* Back link — desktop */} @@ -298,11 +355,17 @@ export default function JourneyDetailPage() { {/* View Controls */}
- {[ - { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, - { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, - { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, - ].map(v => ( + {(isMobile + ? [ + { id: 'timeline' as const, icon: MapPin, label: t('journey.detail.journeyTab') || 'Journey' }, + { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, + ] + : [ + { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, + { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, + { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, + ] + ).map(v => ( ))}
- {view === 'timeline' && ( + {(!isMobile ? view === 'timeline' : view !== 'gallery') && ( )}
- {/* Timeline */} - {view === 'timeline' && ( + {/* Timeline (desktop only — mobile uses fullscreen combined view above) */} + {!isMobile && view === 'timeline' && (
{sortedDates.length === 0 && (
@@ -398,8 +461,8 @@ export default function JourneyDetailPage() { /> )} - {/* Full Map View */} - {view === 'map' &&
- {/* Entry Editor */} - {editingEntry && ( + {/* Entry Editor — portal to body to escape stacking context on iOS */} + {editingEntry && createPortal( + />, + document.body )} {/* Journey Settings */} @@ -2029,8 +2093,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa } return ( -
-
+
+
+

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

@@ -2187,7 +2252,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
{pros.map((p, i) => ( -
+
{cons.map((c, i) => ( -
+
{locationLat && (
- +
)}
@@ -2332,8 +2397,10 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa const active = mood === key return ( @@ -2363,7 +2430,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
-
+
)} - {/* Timeline */} - {view === 'timeline' && perms.share_timeline && ( + {/* Mobile combined map+timeline (public, read-only) */} + {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && ( + ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))} + dark={document.documentElement.classList.contains('dark')} + readOnly + onEntryClick={() => {}} + publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} + /> + )} + + {/* Timeline (desktop, or mobile without map permission) */} + {(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
{sortedDates.map(date => { const dayEntries = groupedEntries.get(date)! From 0e5c819f7c0a1ff10f9b2743e4940cf8bc3c249d Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 16 Apr 2026 23:46:07 +0200 Subject: [PATCH 3/3] fix: adapt tests for last-page-only dismiss and fix editor z-index - SystemNoticeModal tests: navigate to last page before testing X button, ESC, and CTA dismiss (matches new last-page-only behavior) - EntryEditor: use z-[9999] instead of portal (fixes iOS stacking without breaking test DOM queries) - Pros/cons inputs: remove colored backgrounds in dark mode --- .../SystemNotices/SystemNoticeModal.test.tsx | 44 ++++++++++++++----- client/src/pages/JourneyDetailPage.tsx | 17 +++---- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx index 9607f48e..f7b030c3 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx @@ -112,8 +112,9 @@ describe('ModalRenderer', () => { }); it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => { - const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } }); - const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' }); + // CTA is only shown on the last page; navigate there first + const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' }); + const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } }); useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true }); const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss'); @@ -121,6 +122,12 @@ describe('ModalRenderer', () => { await flushGraceDelay(); + // Navigate to last page + await act(async () => { + fireEvent.click(screen.getByLabelText('Go to notice 2')); + }); + await flushGraceDelay(); + const ctaBtn = screen.getByRole('button', { name: 'Go to trips' }); await act(async () => { fireEvent.click(ctaBtn); @@ -299,17 +306,22 @@ describe('ModalRenderer', () => { expect(screen.getByText('Notice A')).toBeTruthy(); expect(screen.getByText('1 / 3')).toBeTruthy(); - // Dismiss notice A — store shrinks, parent re-renders with [B, C] + // Navigate to last page where X button is available await act(async () => { - fireEvent.click(screen.getByLabelText('Dismiss')); - useSystemNoticeStore.setState({ notices: [noticeB, noticeC], loaded: true }); - rerender(); + fireEvent.click(screen.getByLabelText('Go to notice 3')); }); await flushGraceDelay(); - // Must show B (idx=0), not C (idx=1 — the old buggy behavior) - expect(screen.getByText('Notice B')).toBeTruthy(); - expect(screen.getByText('1 / 2')).toBeTruthy(); + // Dismiss all from last page — store shrinks + await act(async () => { + fireEvent.click(screen.getByLabelText('Dismiss')); + useSystemNoticeStore.setState({ notices: [], loaded: true }); + rerender(); + }); + await flushGraceDelay(); + + // All dismissed — modal should be gone + expect(screen.queryByRole('dialog')).toBeNull(); }); it('FE-SN-MODAL-017: X button dismisses all notices, not just the current one', async () => { @@ -321,6 +333,12 @@ describe('ModalRenderer', () => { render(); await flushGraceDelay(); + // X button only appears on the last page — navigate there + await act(async () => { + fireEvent.click(screen.getByLabelText('Go to notice 2')); + }); + await flushGraceDelay(); + await act(async () => { fireEvent.click(screen.getByLabelText('Dismiss')); }); @@ -330,7 +348,7 @@ describe('ModalRenderer', () => { expect(dismissSpy).toHaveBeenCalledTimes(2); }); - it('FE-SN-MODAL-018: ESC key dismisses all notices when current is dismissible', async () => { + it('FE-SN-MODAL-018: ESC key dismisses all notices when on last page', async () => { const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' }); const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' }); useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true }); @@ -339,6 +357,12 @@ describe('ModalRenderer', () => { render(); await flushGraceDelay(); + // ESC only works on last page — navigate there first + await act(async () => { + fireEvent.click(screen.getByLabelText('Go to notice 2')); + }); + await flushGraceDelay(); + await act(async () => { fireEvent.keyDown(document, { key: 'Escape' }); }); diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 295373f6..648fb985 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1,5 +1,4 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react' -import { createPortal } from 'react-dom' import { useParams, useNavigate } from 'react-router-dom' import { useJourneyStore } from '../store/journeyStore' import { useAuthStore } from '../store/authStore' @@ -228,16 +227,15 @@ export default function JourneyDetailPage() { /> )} - {/* Fullscreen entry view (mobile) — portal to body for iOS stacking */} - {viewingEntry && createPortal( + {/* Fullscreen entry view (mobile) */} + {viewingEntry && ( setViewingEntry(null)} onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }} onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }} onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} - />, - document.body + /> )} {/* Floating tab toggle on mobile combined view */} @@ -580,8 +578,8 @@ export default function JourneyDetailPage() {
- {/* Entry Editor — portal to body to escape stacking context on iOS */} - {editingEntry && createPortal( + {/* Entry Editor */} + {editingEntry && ( , - document.body + /> )} {/* Journey Settings */} @@ -2093,7 +2090,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa } return ( -
+