diff --git a/client/src/App.tsx b/client/src/App.tsx index 0a53fa46..941492c2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -100,7 +100,7 @@ function RootRedirect() { } export default function App() { - const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() const { loadAddons } = useAddonStore() @@ -116,7 +116,7 @@ export default function App() { loadUser() } } - authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record }) => { + authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record }) => { if (config?.demo_mode) setDemoMode(true) if (config?.dev_mode) setDevMode(true) if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) @@ -125,6 +125,9 @@ export default function App() { if (config?.timezone) setServerTimezone(config.timezone) if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled) + if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled) + if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled) + if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled) if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) if (config?.version) { diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 4d61426d..50862eea 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -272,6 +272,12 @@ export const adminApi = { checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data), updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data), + getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data), + updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data), + getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data), + updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data), + getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data), + updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data), getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data), updateCollabFeatures: (features: Record) => apiClient.put('/admin/collab-features', features).then(r => r.data), packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data), @@ -331,8 +337,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) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data), deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data), diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index a5c5b262..c36f8432 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/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 4363b4e3..0f9a5a36 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -14,6 +14,7 @@ export interface MapMarkerItem { export interface JourneyMapHandle { highlightMarker: (id: string | null) => void focusMarker: (id: string) => void + invalidateSize: () => void } interface MapEntry { @@ -151,7 +152,11 @@ const JourneyMap = forwardRef(function JourneyMap( } }, []) - useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), []) + const invalidateSize = useCallback(() => { + try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ } + }, []) + + useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), []) useEffect(() => { if (!containerRef.current) return 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/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index a1a2d5be..6bec003f 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -68,9 +68,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) { ">${label}` } - // Base64 data URL thumbnails — no external image fetch during zoom - // Only use base64 data URLs for markers — external URLs cause zoom lag - if (place.image_url && place.image_url.startsWith('data:')) { + // Prefer base64 data URLs (no zoom lag); also accept same-origin proxy URLs as a fallback + // while the thumb is still being generated in the background + if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/'))) { const imgIcon = L.divIcon({ className: '', html: `
{/* Main area */} diff --git a/client/src/components/SystemNotices/SystemNoticeModal.tsx b/client/src/components/SystemNotices/SystemNoticeModal.tsx index e010bc24..82ae2189 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 { 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 ( -
+
{/* Dismiss X button — only on last page so users read all notices */} {notice.dismissible && isLastPage && ( )} - {/* Hero image (not inline) */} - {notice.media && notice.media.placement !== 'inline' && ( -
- {t(notice.media.altKey)} { (e.target as HTMLImageElement).style.display = 'none'; }} - /> -
- )} - - {/* Special warm header for Heart icon (thank-you notice) */} - {notice.icon === 'Heart' && !notice.media && ( -
-
-
-
- -
-
-

{title}

-

TREK 3.0

-
-
-
- )} - -
- {/* Severity icon (when no hero and not Heart) */} - {!notice.media && notice.icon !== 'Heart' && ( -
- -
- )} - - {/* Title (not for Heart — rendered in gradient header) */} - {(notice.icon !== 'Heart' || notice.media) && ( -

- {title} -

- )} - - {/* Body — markdown (long body text uses left-aligned layout) */} -
- {body}

}> - ( - - {children} - - ), - 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

{children}

; - } - return

{children}

; - }, - hr: () => ( -
-
- -
-
- ), - strong: ({ children }) => {children}, - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - }} - > - {body} - - -
- - {/* Inline image */} - {notice.media?.placement === 'inline' && ( + {/* Scrollable content — vertically centered when shorter than available space */} +
+ {/* Hero image (not inline) */} + {notice.media && notice.media.placement !== 'inline' && (
{t(notice.media.altKey)} { (e.target as HTMLImageElement).style.display = 'none'; }} />
)} - {/* Highlights */} - {notice.highlights && notice.highlights.length > 0 && ( -
    - {notice.highlights.map((h, i) => { - const HIcon: React.ElementType | null = h.iconName - ? ((LucideIcons as Record)[h.iconName] as React.ElementType) ?? null - : null; - return ( -
  • - {HIcon - ? - : - } - {t(h.labelKey)} -
  • - ); - })} -
+ {/* Special warm header for Heart icon (thank-you notice) */} + {notice.icon === 'Heart' && !notice.media && ( +
+
+
+
+ +
+
+

{title}

+

TREK 3.0

+
+
+
)} +
+ {/* Severity icon (when no hero and not Heart) */} + {!notice.media && notice.icon !== 'Heart' && ( +
+ +
+ )} + + {/* Title (not for Heart — rendered in gradient header) */} + {(notice.icon !== 'Heart' || notice.media) && ( +

+ {title} +

+ )} + + {/* Body — markdown */} +
+ {body}

}> + ( + + {children} + + ), + 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

{children}

; + } + return

{children}

; + }, + hr: () => ( +
+
+ +
+
+ ), + strong: ({ children }) => {children}, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + }} + > + {body} + + +
+ + {/* Inline image */} + {notice.media?.placement === 'inline' && ( +
+ {t(notice.media.altKey)} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+ )} + + {/* Highlights */} + {notice.highlights && notice.highlights.length > 0 && ( +
    + {notice.highlights.map((h, i) => { + const HIcon: React.ElementType | null = h.iconName + ? ((LucideIcons as Record)[h.iconName] as React.ElementType) ?? null + : null; + return ( +
  • + {HIcon + ? + : + } + {t(h.labelKey)} +
  • + ); + })} +
+ )} +
+
+ + {/* Sticky footer — pager + CTA, always anchored at the bottom of the slot */} +
{/* Pager — dots, arrows, counter (only when multiple notices) */} {total > 1 && ( -
+
@@ -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" > @@ -261,17 +271,8 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, )} {/* CTA + dismiss link */} -
- {!isLastPage && total > 1 ? ( - /* Non-last page: "Next" button to advance through all notices */ - - ) : ctaLabel ? ( +
+ {ctaLabel && isLastPage ? ( - ) : ( + ) : (notice.dismissible || isLastPage) && ( +
+ + {/* Place Autocomplete Toggle */} +
+
+

{t('admin.placesAutocomplete.title')}

+

{t('admin.placesAutocomplete.subtitle')}

+
+ +
+ + {/* Place Details Toggle */} +
+
+

{t('admin.placesDetails.title')}

+

{t('admin.placesDetails.subtitle')}

+
+ +
+ {/* Open-Meteo Weather Info */}
@@ -1180,6 +1252,7 @@ export default function AdminPage(): React.ReactElement { const emailActive = activeChans.includes('email') const webhookActive = activeChans.includes('webhook') const ntfyActive = activeChans.includes('ntfy') + const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false' const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => { const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none' @@ -1338,6 +1411,37 @@ export default function AdminPage(): React.ReactElement {
+ {/* Trip Reminders Toggle */} +
+
+
+

{t('admin.notifications.tripReminders.title')}

+

{t('admin.notifications.tripReminders.hint')}

+
+ +
+
+ {/* Admin Webhook Panel */}
diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index 4b7aad9d..8b93c175 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -176,7 +176,7 @@ const mockJourneyDetail = { avatar: null, }, ], - stats: { entries: 2, photos: 1, cities: 2 }, + stats: { entries: 2, photos: 1, places: 2 }, }; // ── MSW Handlers ───────────────────────────────────────────────────────────── @@ -265,8 +265,8 @@ describe('JourneyDetailPage', () => { await renderAndWait(); const timelineBtn = screen.getByRole('button', { name: /timeline/i }); expect(timelineBtn).toBeInTheDocument(); - // Timeline entries are visible by default - expect(screen.getByText('Arrived in Rome')).toBeInTheDocument(); + // Timeline entries are visible by default (gallery also mounted but hidden, so multiple matches are expected) + expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1); }); }); @@ -274,8 +274,8 @@ describe('JourneyDetailPage', () => { describe('FE-PAGE-JOURNEYDETAIL-004: Shows entry cards with titles', () => { it('renders all entry titles in timeline view', async () => { await renderAndWait(); - expect(screen.getByText('Arrived in Rome')).toBeInTheDocument(); - expect(screen.getByText('Florence Day')).toBeInTheDocument(); + expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1); }); }); @@ -362,12 +362,12 @@ describe('JourneyDetailPage', () => { expect(screen.getAllByText('Days').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('Entries').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('Photos').length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText('Cities').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Places').length).toBeGreaterThanOrEqual(1); }); it('renders stat values', async () => { await renderAndWait(); - // stats.entries = 2, stats.photos = 1, stats.cities = 2 + // stats.entries = 2, stats.photos = 1, stats.places = 2 // Entries count appears in hero and sidebar const twos = screen.getAllByText('2'); expect(twos.length).toBeGreaterThanOrEqual(1); @@ -474,7 +474,7 @@ describe('JourneyDetailPage', () => { // ── FE-PAGE-JOURNEYDETAIL-018 ────────────────────────────────────────── describe('FE-PAGE-JOURNEYDETAIL-018: Empty state when no entries', () => { it('shows "No entries yet" when journey has no entries', async () => { - setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } }); + setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } }); render(); @@ -484,7 +484,7 @@ describe('JourneyDetailPage', () => { }); it('shows hint text to add a trip', async () => { - setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } }); + setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } }); render(); @@ -567,7 +567,7 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [multiPhotoEntry, mockJourneyDetail.entries[1]], - stats: { entries: 2, photos: 3, cities: 2 }, + stats: { entries: 2, photos: 3, places: 2 }, }); render(); @@ -610,12 +610,12 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [...mockJourneyDetail.entries, skeletonEntry], - stats: { entries: 3, photos: 1, cities: 3 }, + stats: { entries: 3, photos: 1, places: 3 }, }); render(); await waitFor(() => { - expect(screen.getByText('Venice Visit')).toBeInTheDocument(); + expect(screen.getAllByText('Venice Visit').length).toBeGreaterThanOrEqual(1); }); // Skeleton card shows "Add Entry" CTA @@ -650,15 +650,15 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [...mockJourneyDetail.entries, checkinEntry], - stats: { entries: 3, photos: 1, cities: 2 }, + stats: { entries: 3, photos: 1, places: 2 }, }); render(); await waitFor(() => { - expect(screen.getByText('Quick stop at cafe')).toBeInTheDocument(); + expect(screen.getAllByText('Quick stop at cafe').length).toBeGreaterThanOrEqual(1); }); - expect(screen.getByText(/Cafe Roma/)).toBeInTheDocument(); + expect(screen.getAllByText(/Cafe Roma/).length).toBeGreaterThanOrEqual(1); expect(screen.getByText('Grabbed an espresso')).toBeInTheDocument(); }); }); @@ -707,15 +707,26 @@ describe('JourneyDetailPage', () => { // ── FE-PAGE-JOURNEYDETAIL-030 ────────────────────────────────────────── describe('FE-PAGE-JOURNEYDETAIL-030: Active status badge shows Live indicator', () => { - it('renders a "Live" badge for active journeys', async () => { + it('renders a "Live" badge when linked trip spans today', async () => { + setupDefaultHandlers({ + trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }], + }); await renderAndWait(); expect(screen.getByText('Live')).toBeInTheDocument(); }); + + it('does not render "Live" badge when linked trip is in the past', async () => { + await renderAndWait(); + expect(screen.queryByText('Live')).not.toBeInTheDocument(); + }); }); // ── FE-PAGE-JOURNEYDETAIL-031 ────────────────────────────────────────── describe('FE-PAGE-JOURNEYDETAIL-031: Synced with Trips badge renders', () => { - it('renders the "Synced with Trips" text in the hero', async () => { + it('renders the "Synced with Trips" text in the hero for live journeys', async () => { + setupDefaultHandlers({ + trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }], + }); await renderAndWait(); expect(screen.getByText('Synced with Trips')).toBeInTheDocument(); }); @@ -741,7 +752,7 @@ describe('JourneyDetailPage', () => { it('shows the place count in the sidebar map', async () => { await renderAndWait(); // The sidebar map shows "N Places" text - expect(screen.getByText(/Places/)).toBeInTheDocument(); + expect(screen.getAllByText(/Places/).length).toBeGreaterThanOrEqual(1); }); }); @@ -1106,8 +1117,9 @@ describe('JourneyDetailPage', () => { // Map view renders a location list with entry titles/location names // The MapView component shows entry names in clickable location items - expect(screen.getByText('Arrived in Rome')).toBeInTheDocument(); - expect(screen.getByText('Florence Day')).toBeInTheDocument(); + // (timeline is still mounted but hidden, so multiple matches are expected) + expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1); }); }); @@ -1166,8 +1178,8 @@ describe('JourneyDetailPage', () => { expect(dayBadges.length).toBeGreaterThanOrEqual(2); // Each day group shows its entries - expect(screen.getByText('Arrived in Rome')).toBeInTheDocument(); - expect(screen.getByText('Florence Day')).toBeInTheDocument(); + expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1); }); }); @@ -1717,7 +1729,7 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [emptyEntry], - stats: { entries: 1, photos: 0, cities: 1 }, + stats: { entries: 1, photos: 0, places: 1 }, }); render(); @@ -1867,8 +1879,10 @@ describe('JourneyDetailPage', () => { expect(screen.getAllByTestId('journey-map').length).toBeGreaterThanOrEqual(1); }); - // Click the "Arrived in Rome" location item - const romeItem = screen.getByText('Arrived in Rome'); + // Click the "Arrived in Rome" location item in the map view's location list + // (timeline is still mounted but hidden, so find the one inside a cursor-pointer container) + const romeItems = screen.getAllByText('Arrived in Rome'); + const romeItem = romeItems.find(el => el.closest('[class*="cursor-pointer"]')) ?? romeItems[0]; await user.click(romeItem); // After clicking, the item should gain active styles (translate-x-0.5 on the container) @@ -1930,7 +1944,7 @@ describe('JourneyDetailPage', () => { { ...mockJourneyDetail.entries[0], id: 10, entry_date: '2026-03-15' }, { ...mockJourneyDetail.entries[1], id: 11, entry_date: '2026-03-15', location_lat: 41.95, location_lng: 12.55 }, ]; - setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, cities: 2 } }); + setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, places: 2 } }); render(); await waitFor(() => { @@ -2005,7 +2019,7 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [immichEntry, mockJourneyDetail.entries[1]], - stats: { entries: 2, photos: 1, cities: 2 }, + stats: { entries: 2, photos: 1, places: 2 }, }); render(); @@ -2039,7 +2053,7 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [synologyEntry, mockJourneyDetail.entries[1]], - stats: { entries: 2, photos: 1, cities: 2 }, + stats: { entries: 2, photos: 1, places: 2 }, }); render(); @@ -2636,7 +2650,7 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [multiPhotoEntry, mockJourneyDetail.entries[1]], - stats: { entries: 2, photos: 5, cities: 2 }, + stats: { entries: 2, photos: 5, places: 2 }, }); render(); @@ -2661,7 +2675,7 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [twoPhotoEntry, mockJourneyDetail.entries[1]], - stats: { entries: 2, photos: 2, cities: 2 }, + stats: { entries: 2, photos: 2, places: 2 }, }); render(); @@ -3045,7 +3059,7 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [mockJourneyDetail.entries[0], noLocEntry], - stats: { entries: 2, photos: 1, cities: 1 }, + stats: { entries: 2, photos: 1, places: 1 }, }); render(); @@ -3528,7 +3542,7 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [entryWithMultiPhotos, mockJourneyDetail.entries[1]], - stats: { entries: 2, photos: 2, cities: 2 }, + stats: { entries: 2, photos: 2, places: 2 }, }); server.use( @@ -3620,7 +3634,7 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [mockJourneyDetail.entries[0], noTitleEntry], - stats: { entries: 2, photos: 1, cities: 2 }, + stats: { entries: 2, photos: 1, places: 2 }, }); render(); diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index b1bd9931..5b8f94cb 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -24,6 +24,7 @@ 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' +import { computeJourneyLifecycle } from '../utils/journeyLifecycle' const GRADIENTS = [ 'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)', @@ -163,6 +164,12 @@ export default function JourneyDetailPage() { setActiveLocationId(id) }, []) + useEffect(() => { + if (view === 'map') { + requestAnimationFrame(() => fullMapRef.current?.invalidateSize()) + } + }, [view]) + const mapEntries = useMemo( () => (current?.entries || []).filter(e => e.location_lat && e.location_lng), [current?.entries] @@ -207,6 +214,14 @@ export default function JourneyDetailPage() { const dayGroups = groupByDate(timelineEntries) const sortedDates = [...dayGroups.keys()].sort() + const tripDateMin = current.trips.length + ? current.trips.reduce((min: string, t: any) => t.start_date && (!min || t.start_date < min) ? t.start_date : min, '') + : null + const tripDateMax = current.trips.length + ? current.trips.reduce((max: string, t: any) => t.end_date && (!max || t.end_date > max) ? t.end_date : max, '') + : null + const lifecycle = computeJourneyLifecycle(current.status, tripDateMin || null, tripDateMax || null) + const showMobileCombined = isMobile && view === 'timeline' return ( @@ -283,16 +298,28 @@ export default function JourneyDetailPage() {
{/* Desktop: badges */}
- {current.status === 'active' && ( + {lifecycle === 'live' && (
- Live + {t('journey.frontpage.live')} +
+ )} + {lifecycle !== 'archived' && current.trips.length > 0 && ( +
+ + {t('journey.detail.syncedWithTrips')} +
+ )} + {lifecycle !== 'live' && lifecycle !== 'archived' && ( +
+ {t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)} +
+ )} + {lifecycle === 'archived' && ( +
+ {t('journey.status.archived')}
)} -
- - {t('journey.detail.syncedWithTrips')} -
{/* Mobile: back button on the left */}
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */} - {!isMobile && view === 'timeline' && ( -
+ {!isMobile && ( +
{sortedDates.length === 0 && (
@@ -448,7 +475,7 @@ export default function JourneyDetailPage() { )} {/* Gallery View */} - {view === 'gallery' && ( +
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 })} onRefresh={() => loadJourney(Number(id))} /> - )} +
{/* Full Map View (desktop only — mobile uses combined view) */} - {!isMobile && view === 'map' &&
} + {!isMobile && ( +
+ +
+ )}
{/* Right sidebar — hidden on mobile */} @@ -494,7 +525,7 @@ export default function JourneyDetailPage() { { value: sortedDates.length, label: t('journey.stats.days') }, { value: current.stats.entries, label: t('journey.stats.entries') }, { value: current.stats.photos, label: t('journey.stats.photos') }, - { value: current.stats.cities, label: t('journey.stats.cities') }, + { value: current.stats.places, label: t('journey.stats.places') }, ].map(s => (
{s.value}
@@ -1021,7 +1052,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 (groups, entryId) => { let targetId = entryId if (!targetId) { try { @@ -1034,10 +1065,12 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres } catch { return } } let added = 0 - try { - const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds) - added = result.added || 0 - } catch {} + for (const group of groups) { + try { + const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase) + added += result.added || 0 + } catch {} + } if (added > 0) { toast.success(t('journey.photosAdded', { count: added })) onRefresh() @@ -1511,7 +1544,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on trips: JourneyTrip[] existingAssetIds: Set onClose: () => void - onAdd: (assetIds: string[], entryId: number | null) => Promise + onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise }) { const { t } = useTranslation() const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip') @@ -1525,7 +1558,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on const [searchPage, setSearchPage] = useState(1) const [searchFrom, setSearchFrom] = useState('') const [searchTo, setSearchTo] = useState('') - const [selected, setSelected] = useState>(new Set()) + const [selected, setSelected] = useState>(new Map()) const [customFrom, setCustomFrom] = useState('') const [customTo, setCustomTo] = useState('') const [targetEntryId, setTargetEntryId] = useState(null) @@ -1617,8 +1650,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on const toggleAsset = (id: string) => { setSelected(prev => { - const next = new Set(prev) - if (next.has(id)) next.delete(id); else next.add(id) + const next = new Map(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }) + } return next }) } @@ -1780,9 +1817,9 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on + + {/* Header — mobile */} +
+
+ + +
+ {searchOpen && ( + setSearchQuery(e.target.value)} + onKeyDown={e => { if (e.key === 'Escape') { setSearchQuery(''); setSearchOpen(false) } }} + placeholder={t('journey.search.placeholder')} + className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-400 focus:outline-none" + /> + )}
{/* Header — desktop */} @@ -117,8 +157,24 @@ export default function JourneyPage() {

{t("journey.frontpage.subtitle")}

-