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

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

diff --git a/client/src/pages/JourneyPage.tsx b/client/src/pages/JourneyPage.tsx index 564cdddd..1c094eb6 100644 --- a/client/src/pages/JourneyPage.tsx +++ b/client/src/pages/JourneyPage.tsx @@ -279,7 +279,7 @@ export default function JourneyPage() { {/* Create Modal */} {showCreate && (
-
+
{/* Header */}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 0f152392..5c24201d 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -838,7 +838,7 @@ export default function TripPlannerPage(): React.ReactElement | null { )} {selectedPlace && isMobile && ReactDOM.createPortal( -
setSelectedPlaceId(null)}> +
setSelectedPlaceId(null)}>
e.stopPropagation()}> Date: Thu, 16 Apr 2026 23:36:33 +0200 Subject: [PATCH 02/16] fix: getAppVersion now getting 1st from environment, fallback to package.json, fallback to 0.0.0 if all failed --- server/src/systemNotices/service.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/server/src/systemNotices/service.ts b/server/src/systemNotices/service.ts index d962ed90..13c16b1b 100644 --- a/server/src/systemNotices/service.ts +++ b/server/src/systemNotices/service.ts @@ -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 { From a1f3b4476e4b605da10c00bb565b65d16c2e8490 Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 17 Apr 2026 15:06:23 +0200 Subject: [PATCH 03/16] fix(system-notices): overhaul mobile bottom sheet UX - Replace "Next Notice >" CTA with proper < > pager buttons - Fix shared scroll container: each slot now scrolls independently - Sheet uses fixed h-[85dvh] so height is consistent across all notices - Sticky footer (pager + CTA) always anchored at bottom of each slot - Content area vertically centered when shorter than available space - Dismiss-drag suppressed when slot is scrolled down (pan-up to scroll back) - Scroll position resets on navigation via per-slot refs - Adjacent slot scroll cleared on horizontal gesture classification - OK button navigates to next notice on non-last pages, dismisses on last - OK button only shown when dismissible or on last notice --- .../SystemNotices/SystemNoticeModal.tsx | 337 +++++++++--------- 1 file changed, 174 insertions(+), 163 deletions(-) diff --git a/client/src/components/SystemNotices/SystemNoticeModal.tsx b/client/src/components/SystemNotices/SystemNoticeModal.tsx index 837bef10..82ae2189 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useLayoutEffect, useRef } from 'react'; +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'; @@ -71,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 && ( -
+
@@ -247,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" > @@ -262,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) && (
+ {/* 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..d3b005cd 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 ───────────────────────────────────────────────────────────── @@ -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,7 +610,7 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [...mockJourneyDetail.entries, skeletonEntry], - stats: { entries: 3, photos: 1, cities: 3 }, + stats: { entries: 3, photos: 1, places: 3 }, }); render(); @@ -650,7 +650,7 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [...mockJourneyDetail.entries, checkinEntry], - stats: { entries: 3, photos: 1, cities: 2 }, + stats: { entries: 3, photos: 1, places: 2 }, }); render(); @@ -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); }); }); @@ -1717,7 +1728,7 @@ describe('JourneyDetailPage', () => { }; setupDefaultHandlers({ entries: [emptyEntry], - stats: { entries: 1, photos: 0, cities: 1 }, + stats: { entries: 1, photos: 0, places: 1 }, }); render(); @@ -1930,7 +1941,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 +2016,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 +2050,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 +2647,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 +2672,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 +3056,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 +3539,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 +3631,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 a2004fdf..f7002877 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%)', @@ -207,6 +208,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 +292,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 */} + + {/* 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")}

- +
+ {/* Open-Meteo Weather Info */}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 5c24201d..020be261 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -28,6 +28,7 @@ import { useTranslation } from '../i18n' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client' import { accommodationRepo } from '../repo/accommodationRepo' import { offlineDb } from '../db/offlineDb' +import { useAuthStore } from '../store/authStore' import ConfirmDialog from '../components/shared/ConfirmDialog' import { useResizablePanels } from '../hooks/useResizablePanels' import { useTripWebSocket } from '../hooks/useTripWebSocket' @@ -75,6 +76,7 @@ export default function TripPlannerPage(): React.ReactElement | null { const toast = useToast() const { t, language } = useTranslation() const { settings } = useSettingsStore() + const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled) const trip = useTripStore(s => s.trip) const days = useTripStore(s => s.days) const places = useTripStore(s => s.places) @@ -178,7 +180,7 @@ export default function TripPlannerPage(): React.ReactElement | null { // Start photo fetches during splash screen so images are ready when map mounts useEffect(() => { - if (isLoading || !places || places.length === 0) return + if (isLoading || !places || places.length === 0 || !placesPhotosEnabled) return for (const p of places) { if (p.image_url) continue const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}` @@ -900,7 +902,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} /> : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} /> }
diff --git a/client/src/services/photoService.ts b/client/src/services/photoService.ts index d5fc3682..c80c7fa4 100644 --- a/client/src/services/photoService.ts +++ b/client/src/services/photoService.ts @@ -85,6 +85,19 @@ export function fetchPhoto( return } + // If photoId is already our stable proxy URL, use it directly — no API round-trip needed + if (photoId && photoId.startsWith('/api/maps/place-photo/')) { + const entry: PhotoEntry = { photoUrl: photoId, thumbDataUrl: null } + cache.set(cacheKey, entry) + callback?.(entry) + notify(cacheKey, entry) + // Generate base64 thumb in background + urlToBase64(photoId).then(thumb => { + if (thumb) { entry.thumbDataUrl = thumb; notifyThumb(cacheKey, thumb) } + }) + return + } + inFlight.add(cacheKey) mapsApi.placePhoto(photoId, lat, lng, name) .then(async (data: { photoUrl?: string }) => { diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 74fe01e1..6c6efbf9 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -33,6 +33,7 @@ interface AuthState { /** Server policy: all users must enable MFA */ appRequireMfa: boolean tripRemindersEnabled: boolean + placesPhotosEnabled: boolean login: (email: string, password: string) => Promise completeMfaLogin: (mfaToken: string, code: string) => Promise @@ -53,6 +54,7 @@ interface AuthState { setServerTimezone: (tz: string) => void setAppRequireMfa: (val: boolean) => void setTripRemindersEnabled: (val: boolean) => void + setPlacesPhotosEnabled: (val: boolean) => void demoLogin: () => Promise } @@ -74,6 +76,7 @@ export const useAuthStore = create()( serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, appRequireMfa: false, tripRemindersEnabled: false, + placesPhotosEnabled: true, login: async (email: string, password: string) => { authSequence++ @@ -257,6 +260,7 @@ export const useAuthStore = create()( setServerTimezone: (tz: string) => set({ serverTimezone: tz }), setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }), setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }), + setPlacesPhotosEnabled: (val: boolean) => set({ placesPhotosEnabled: val }), demoLogin: async () => { authSequence++ diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index e79decef..d4786fa4 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1634,6 +1634,45 @@ function runMigrations(db: Database.Database): void { try { db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, + // Migration 105: Persistent Google place photo disk cache registry + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS google_place_photo_meta ( + place_id TEXT PRIMARY KEY, + attribution TEXT, + fetched_at INTEGER NOT NULL, + error_at INTEGER + ) + `); + }, + // Migration 106: Persistent Place Details row cache + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS place_details_cache ( + place_id TEXT NOT NULL, + lang TEXT NOT NULL DEFAULT '', + expanded INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + PRIMARY KEY (place_id, lang, expanded) + ) + `); + }, + // Migration 107: Backfill expired signed Google photo URLs to stable proxy URLs + { raw: () => { + db.exec(` + UPDATE places + SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes', + updated_at = CURRENT_TIMESTAMP + WHERE google_place_id IS NOT NULL + AND image_url IS NOT NULL + AND image_url != '' + AND ( + (image_url LIKE '%googleusercontent.com%' AND image_url LIKE '%/places/%/photos/%') + OR (image_url LIKE '%places.googleapis.com%' AND image_url LIKE '%/places/%/photos/%') + ) + `); + }}, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index ab873e52..58cf4749 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -201,6 +201,25 @@ router.put('/bag-tracking', (req: Request, res: Response) => { res.json(result); }); +// ── Places Photos ─────────────────────────────────────────────────────── + +router.get('/places-photos', (_req: Request, res: Response) => { + res.json(svc.getPlacesPhotos()); +}); + +router.put('/places-photos', (req: Request, res: Response) => { + if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' }); + const result = svc.updatePlacesPhotos(req.body.enabled); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.places_photos', + ip: getClientIp(req), + details: { enabled: result.enabled }, + }); + res.json(result); +}); + // ── Collab Features ─────────────────────────────────────────────────────── router.get('/collab-features', (_req: Request, res: Response) => { diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index 57867095..9692747f 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -4,11 +4,14 @@ import { AuthRequest } from '../types'; import { searchPlaces, getPlaceDetails, + getPlaceDetailsExpanded, getPlacePhoto, reverseGeocode, resolveGoogleMapsUrl, autocompletePlaces, } from '../services/mapsService'; +import { db } from '../db/database'; +import { serveFilePath } from '../services/placePhotoCache'; const router = express.Router(); @@ -72,9 +75,13 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) = router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { placeId } = req.params; + const expand = req.query.expand as string | undefined; try { - const result = await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string); + const refresh = req.query.refresh === '1'; + const result = expand + ? await getPlaceDetailsExpanded(authReq.user.id, placeId, req.query.lang as string, refresh) + : await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string); res.json(result); } catch (err: unknown) { const status = (err as { status?: number }).status || 500; @@ -88,6 +95,12 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { placeId } = req.params; + + // Kill-switch only applies to Google Places API fetches — Wikimedia (coords: prefix) is always allowed + if (!placeId.startsWith('coords:')) { + const photosEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined; + if (photosEnabledRow?.value === 'false') return res.status(200).json({ photoUrl: null }); + } const lat = parseFloat(req.query.lat as string); const lng = parseFloat(req.query.lng as string); @@ -102,6 +115,15 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp } }); +// GET /place-photo/:placeId/bytes — serve cached photo bytes from disk +router.get('/place-photo/:placeId/bytes', authenticate, (req: Request, res: Response) => { + const { placeId } = req.params; + const fp = serveFilePath(placeId); + if (!fp) return res.status(404).json({ error: 'Photo not cached' }); + res.set('Cache-Control', 'public, max-age=2592000, immutable'); + res.sendFile(fp); +}); + // GET /reverse router.get('/reverse', authenticate, async (req: Request, res: Response) => { const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string }; diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 81674b54..8f1a9344 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -459,6 +459,18 @@ export function updateBagTracking(enabled: boolean) { return { enabled: !!enabled }; } +// ── Places Photos ───────────────────────────────────────────────────────── + +export function getPlacesPhotos() { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined; + return { enabled: row?.value !== 'false' }; +} + +export function updatePlacesPhotos(enabled: boolean) { + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(enabled ? 'true' : 'false'); + return { enabled: !!enabled }; +} + // ── Collab Features ─────────────────────────────────────────────────────── const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const; diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 33660c58..31c71326 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -229,6 +229,8 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean); const hasWebhookEnabled = activeChannels.includes('webhook'); const tripRemindersEnabled = tripReminderSetting !== 'false'; + const placesPhotosSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined)?.value; + const placesPhotosEnabled = placesPhotosSetting !== 'false'; const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get()); return { @@ -258,6 +260,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { notification_channels: activeChannels, available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true }, trip_reminders_enabled: tripRemindersEnabled, + places_photos_enabled: placesPhotosEnabled, permissions: authenticatedUser ? getAllPermissions() : undefined, dev_mode: process.env.NODE_ENV === 'development', }; diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index ca886cea..30827857 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -2,6 +2,19 @@ import { db } from '../db/database'; import { decrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; +// ── Google API call counter ─────────────────────────────────────────────────── + +let googleApiCallCount = 0; + +export function getGoogleApiCallCount(): number { return googleApiCallCount; } +export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; } + +function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise { + googleApiCallCount++; + console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`); + return fetch(endpoint, init); +} + // ── Interfaces ─────────────────────────────────────────────────────────────── interface NominatimResult { @@ -55,26 +68,8 @@ interface GooglePlaceDetails extends GooglePlaceResult { const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)'; -// ── Photo cache ────────────────────────────────────────────────────────────── - -const photoCache = new Map(); -const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours -const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors -const CACHE_MAX_ENTRIES = 1000; -const CACHE_PRUNE_TARGET = 500; -const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes - -setInterval(() => { - const now = Date.now(); - for (const [key, entry] of photoCache) { - if (now - entry.fetchedAt > PHOTO_TTL) photoCache.delete(key); - } - if (photoCache.size > CACHE_MAX_ENTRIES) { - const entries = [...photoCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt); - const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET); - toDelete.forEach(([key]) => photoCache.delete(key)); - } -}, CACHE_CLEANUP_INTERVAL); +// ── Photo cache (disk-backed) ──────────────────────────────────────────────── +import * as placePhotoCache from './placePhotoCache'; // ── API key retrieval ──────────────────────────────────────────────────────── @@ -311,7 +306,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string) return { places, source: 'openstreetmap' }; } - const response = await fetch('https://places.googleapis.com/v1/places:searchText', { + const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -371,7 +366,7 @@ export async function autocompletePlaces( }; } - const response = await fetch('https://places.googleapis.com/v1/places:autocomplete', { + const response = await googleFetch('https://places.googleapis.com/v1/places:autocomplete', 'autocomplete', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -451,12 +446,79 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st } // Google details + const langKey = lang || 'de'; const apiKey = getMapsKey(userId); if (!apiKey) { throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 }); } - const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang || 'de'}`, { + // Check DB cache first (lean mask, expanded=0) — 7-day TTL + const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000; + const cached = db.prepare( + 'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0' + ).get(placeId, langKey) as { payload_json: string; fetched_at: number } | undefined; + if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) }; + + const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetails(${placeId})`, { + method: 'GET', + headers: { + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri', + }, + }); + + const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } }; + + if (!response.ok) { + const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number }; + err.status = response.status; + throw err; + } + + const place = { + google_place_id: data.id, + name: data.displayName?.text || '', + address: data.formattedAddress || '', + lat: data.location?.latitude || null, + lng: data.location?.longitude || null, + rating: data.rating || null, + rating_count: data.userRatingCount || null, + website: data.websiteUri || null, + phone: data.nationalPhoneNumber || null, + opening_hours: data.regularOpeningHours?.weekdayDescriptions || null, + open_now: data.regularOpeningHours?.openNow ?? null, + google_maps_url: data.googleMapsUri || null, + summary: null, + reviews: [], + source: 'google' as const, + cached_at: Date.now(), + }; + + try { + db.prepare( + 'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)' + ).run(placeId, langKey, JSON.stringify(place), Date.now()); + } catch (dbErr) { + console.error('Failed to cache place details:', dbErr); + } + + return { place }; +} + +export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record }> { + const langKey = lang || 'de'; + const apiKey = getMapsKey(userId); + if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 }); + + // Check DB cache for expanded result + if (!refresh) { + const cached = db.prepare( + 'SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1' + ).get(placeId, langKey) as { payload_json: string } | undefined; + if (cached) return { place: JSON.parse(cached.payload_json) }; + } + + const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetailsExpanded(${placeId})`, { method: 'GET', headers: { 'X-Goog-Api-Key': apiKey, @@ -494,12 +556,21 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st photo: r.authorAttribution?.photoUri || null, })), source: 'google' as const, + cached_at: Date.now(), }; + try { + db.prepare( + 'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)' + ).run(placeId, langKey, JSON.stringify(place), Date.now()); + } catch (dbErr) { + console.error('Failed to cache expanded place details:', dbErr); + } + return { place }; } -// ── Place photo (Google or Wikimedia, with caching + DB persistence) ───────── +// ── Place photo (Google or Wikimedia, disk-cached) ──────────────────────────── export async function getPlacePhoto( userId: number, @@ -508,84 +579,110 @@ export async function getPlacePhoto( lng: number, name?: string, ): Promise<{ photoUrl: string; attribution: string | null }> { - // Check cache first - const cached = photoCache.get(placeId); - if (cached) { - const ttl = cached.error ? ERROR_TTL : PHOTO_TTL; - if (Date.now() - cached.fetchedAt < ttl) { - if (cached.error) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 }); - return { photoUrl: cached.photoUrl, attribution: cached.attribution }; + // Disk cache hit — serve immediately, no Google call + const diskHit = placePhotoCache.get(placeId); + if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution }; + + // Recent error — don't hammer the API + if (placePhotoCache.getErrored(placeId)) { + throw Object.assign(new Error('(Cache) No photo available'), { status: 404 }); + } + + // Deduplicate concurrent requests for the same placeId + const existing = placePhotoCache.getInFlight(placeId); + if (existing) { + const result = await existing; + if (!result) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 }); + return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution }; + } + + const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => { + const apiKey = getMapsKey(userId); + const isCoordLookup = placeId.startsWith('coords:'); + + // No Google key or coordinate-only lookup → try Wikimedia (URL-based, not byte-cached) + if (!apiKey || isCoordLookup) { + if (!isNaN(lat) && !isNaN(lng)) { + try { + const wiki = await fetchWikimediaPhoto(lat, lng, name); + if (wiki) { + // Wikimedia photos: fetch bytes and cache to disk + const ssrf = await checkSsrf(wiki.photoUrl, true); + if (!ssrf.allowed) throw Object.assign(new Error('Photo URL blocked'), { status: 403 }); + const imgRes = await fetch(wiki.photoUrl); + if (imgRes.ok) { + const bytes = Buffer.from(await imgRes.arrayBuffer()); + const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution); + return { filePath: cached.filePath, attribution: cached.attribution }; + } + } + } catch { /* fall through */ } + } + placePhotoCache.markError(placeId); + return null; } - photoCache.delete(placeId); - } - const apiKey = getMapsKey(userId); - const isCoordLookup = placeId.startsWith('coords:'); + // Google Photos — fetch details to get photo name + const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, { + headers: { + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'photos', + }, + }); + const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } }; - // No Google key or coordinate-only lookup -> try Wikimedia - if (!apiKey || isCoordLookup) { - if (!isNaN(lat) && !isNaN(lng)) { - try { - const wiki = await fetchWikimediaPhoto(lat, lng, name); - if (wiki) { - photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() }); - return wiki; - } else { - photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); - } - } catch { /* fall through */ } + if (!detailsRes.ok) { + console.error('Google Places photo details error:', details.error?.message || detailsRes.status); + placePhotoCache.markError(placeId); + return null; } - throw Object.assign(new Error('(Wikimedia) No photo available'), { status: 404 }); - } - // Google Photos - const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, { - headers: { - 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': 'photos', - }, - }); - const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } }; + if (!details.photos?.length) { + placePhotoCache.markError(placeId); + return null; + } - if (!detailsRes.ok) { - console.error('Google Places photo details error:', details.error?.message || detailsRes.status); - photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); - throw Object.assign(new Error('(Google Places) Photo could not be retrieved'), { status: 404 }); - } + const photo = details.photos[0]; + const photoName = photo.name; + const attribution = photo.authorAttributions?.[0]?.displayName || null; - if (!details.photos?.length) { - photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); - throw Object.assign(new Error('(Google Places) No photo available'), { status: 404 }); - } + // Fetch actual image bytes + const mediaRes = await googleFetch( + `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`, + `getPlacePhoto/media(${placeId})`, + { headers: { 'X-Goog-Api-Key': apiKey } } + ); - const photo = details.photos[0]; - const photoName = photo.name; - const attribution = photo.authorAttributions?.[0]?.displayName || null; + if (!mediaRes.ok) { + placePhotoCache.markError(placeId); + return null; + } - const mediaRes = await fetch( - `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`, - { headers: { 'X-Goog-Api-Key': apiKey } } - ); - const mediaData = await mediaRes.json() as { photoUri?: string }; - const photoUrl = mediaData.photoUri; + const bytes = Buffer.from(await mediaRes.arrayBuffer()); + if (!bytes.length) { + placePhotoCache.markError(placeId); + return null; + } - if (!photoUrl) { - photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true }); - throw Object.assign(new Error('(Google Places) Photo URL not available'), { status: 404 }); - } + const cached = await placePhotoCache.put(placeId, bytes, attribution); - photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() }); + // Persist stable proxy URL to database + try { + db.prepare( + 'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')' + ).run(cached.photoUrl, placeId); + } catch (dbErr) { + console.error('Failed to persist photo URL to database:', dbErr); + } - // Persist photo URL to database - try { - db.prepare( - 'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = ?)' - ).run(photoUrl, placeId, ''); - } catch (dbErr) { - console.error('Failed to persist photo URL to database:', dbErr); - } + return { filePath: cached.filePath, attribution }; + })(); - return { photoUrl, attribution }; + placePhotoCache.setInFlight(placeId, fetchPromise); + + const result = await fetchPromise; + if (!result) throw Object.assign(new Error('No photo available'), { status: 404 }); + return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution }; } // ── Reverse geocoding ──────────────────────────────────────────────────────── diff --git a/server/src/services/placePhotoCache.ts b/server/src/services/placePhotoCache.ts new file mode 100644 index 00000000..06995e57 --- /dev/null +++ b/server/src/services/placePhotoCache.ts @@ -0,0 +1,95 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import crypto from 'node:crypto'; +import { db } from '../db/database'; + +const GOOGLE_PHOTO_DIR = path.join(__dirname, '../../uploads/photos/google'); +const ERROR_TTL = 5 * 60 * 1000; + +// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously +const inFlight = new Map>(); + +function ensureDir(): void { + if (!fs.existsSync(GOOGLE_PHOTO_DIR)) { + fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true }); + } +} + +function filePath(placeId: string): string { + // Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that + // collapse identically under sanitization (e.g. ':' and '.' both → '_') + const hash = crypto.createHash('sha1').update(placeId).digest('hex'); + return path.join(GOOGLE_PHOTO_DIR, `${hash}.jpg`); +} + +function proxyUrl(placeId: string): string { + return `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`; +} + +interface CachedPhoto { + photoUrl: string; + filePath: string; + attribution: string | null; +} + +export function get(placeId: string): CachedPhoto | null { + const row = db.prepare( + 'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL' + ).get(placeId) as { attribution: string | null } | undefined; + + if (!row) return null; + + const fp = filePath(placeId); + if (!fs.existsSync(fp)) { + // File missing (e.g. volume wiped) — clear row so it refetches + db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId); + return null; + } + + return { photoUrl: proxyUrl(placeId), filePath: fp, attribution: row.attribution }; +} + +export function getErrored(placeId: string): boolean { + const row = db.prepare( + 'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL' + ).get(placeId) as { error_at: number } | undefined; + + if (!row) return false; + return Date.now() - row.error_at < ERROR_TTL; +} + +export function markError(placeId: string): void { + db.prepare( + 'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)' + ).run(placeId, Date.now(), Date.now()); +} + +export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise { + ensureDir(); + const fp = filePath(placeId); + const tmp = fp + '.tmp'; + + await fsPromises.writeFile(tmp, bytes); + await fsPromises.rename(tmp, fp); + + db.prepare( + 'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)' + ).run(placeId, attribution, Date.now()); + + return { photoUrl: proxyUrl(placeId), filePath: fp, attribution }; +} + +export function getInFlight(placeId: string): Promise<{ filePath: string; attribution: string | null } | null> | undefined { + return inFlight.get(placeId); +} + +export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void { + inFlight.set(placeId, promise); + promise.finally(() => inFlight.delete(placeId)); +} + +export function serveFilePath(placeId: string): string | null { + const fp = filePath(placeId); + return fs.existsSync(fp) ? fp : null; +} diff --git a/server/tests/unit/services/mapsService.test.ts b/server/tests/unit/services/mapsService.test.ts index 4399651a..29f8e225 100644 --- a/server/tests/unit/services/mapsService.test.ts +++ b/server/tests/unit/services/mapsService.test.ts @@ -8,10 +8,19 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; -const { mockDbGet, mockDbRun, mockCheckSsrf } = vi.hoisted(() => ({ +const { mockDbGet, mockDbRun, mockCheckSsrf, mockCacheGet, mockCacheGetErrored, mockCachePut, mockCacheGetInFlight, mockCacheSetInFlight } = vi.hoisted(() => ({ mockDbGet: vi.fn(() => undefined as any), mockDbRun: vi.fn(), mockCheckSsrf: vi.fn(async () => ({ allowed: true })), + mockCacheGet: vi.fn(() => null as any), + mockCacheGetErrored: vi.fn(() => false), + mockCachePut: vi.fn(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({ + photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, + filePath: `/tmp/${placeId}.jpg`, + attribution, + })), + mockCacheGetInFlight: vi.fn(() => undefined), + mockCacheSetInFlight: vi.fn(), })); vi.mock('../../../src/db/database', () => ({ @@ -33,6 +42,16 @@ vi.mock('../../../src/config', () => ({ ENCRYPTION_KEY: '0'.repeat(64), })); +vi.mock('../../../src/services/placePhotoCache', () => ({ + get: (placeId: string) => mockCacheGet(placeId), + getErrored: (placeId: string) => mockCacheGetErrored(placeId), + put: (placeId: string, bytes: Buffer, attribution: string | null) => mockCachePut(placeId, bytes, attribution), + markError: vi.fn(), + getInFlight: (placeId: string) => mockCacheGetInFlight(placeId), + setInFlight: (placeId: string, p: Promise) => mockCacheSetInFlight(placeId, p), + serveFilePath: vi.fn(() => null), +})); + import { parseOpeningHours, buildOsmDetails, @@ -46,6 +65,19 @@ afterEach(() => { mockDbRun.mockReset(); mockCheckSsrf.mockReset(); mockCheckSsrf.mockResolvedValue({ allowed: true }); + mockCacheGet.mockReset(); + mockCacheGet.mockReturnValue(null); + mockCacheGetErrored.mockReset(); + mockCacheGetErrored.mockReturnValue(false); + mockCachePut.mockReset(); + mockCachePut.mockImplementation(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({ + photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, + filePath: `/tmp/${placeId}.jpg`, + attribution, + })); + mockCacheGetInFlight.mockReset(); + mockCacheGetInFlight.mockReturnValue(undefined); + mockCacheSetInFlight.mockReset(); }); // ── parseOpeningHours ───────────────────────────────────────────────────────── @@ -995,11 +1027,9 @@ describe('getPlaceDetails (fetch stubbed)', () => { expect(place.rating_count).toBe(200000); expect(place.open_now).toBe(true); expect(place.source).toBe('google'); - expect(place.reviews).toHaveLength(1); - expect(place.reviews[0].author).toBe('John'); - expect(place.reviews[0].rating).toBe(5); - expect(place.reviews[0].text).toBe('Amazing!'); - expect(place.reviews[0].photo).toBe('https://photo.url'); + // Lean mask — reviews/summary not fetched in getPlaceDetails; use getPlaceDetailsExpanded for those + expect(place.reviews).toHaveLength(0); + expect(place.summary).toBeNull(); }); it('MAPS-041c: throws with status when Google API returns non-ok response', async () => { @@ -1016,8 +1046,10 @@ describe('getPlaceDetails (fetch stubbed)', () => { }); }); - it('MAPS-041d: maps reviews with optional fields absent to null', async () => { + it('MAPS-041d: getPlaceDetailsExpanded maps reviews with optional fields absent to null', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + // expanded=1 cache miss → return undefined + mockDbGet.mockReturnValueOnce(undefined); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ @@ -1028,8 +1060,8 @@ describe('getPlaceDetails (fetch stubbed)', () => { ], }), })); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - const result = await getPlaceDetails(1, 'ChIJ456'); + const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService'); + const result = await getPlaceDetailsExpanded(1, 'ChIJ456'); const review = (result.place as any).reviews[0]; expect(review.author).toBeNull(); expect(review.rating).toBeNull(); @@ -1104,8 +1136,10 @@ describe('getPlaceDetails (fetch stubbed)', () => { expect((result.place as any).open_now).toBe(false); }); - it('MAPS-041g: truncates reviews to first 5 entries', async () => { + it('MAPS-041g: getPlaceDetailsExpanded truncates reviews to first 5 entries', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + // expanded=1 cache miss + mockDbGet.mockReturnValueOnce(undefined); const manyReviews = Array.from({ length: 8 }, (_, i) => ({ authorAttribution: { displayName: `User${i}` }, rating: 4, @@ -1116,8 +1150,8 @@ describe('getPlaceDetails (fetch stubbed)', () => { ok: true, json: async () => ({ id: 'ChIJMany', reviews: manyReviews }), })); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - const result = await getPlaceDetails(1, 'ChIJMany'); + const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService'); + const result = await getPlaceDetailsExpanded(1, 'ChIJMany'); expect((result.place as any).reviews).toHaveLength(5); }); }); @@ -1125,16 +1159,26 @@ describe('getPlaceDetails (fetch stubbed)', () => { // ── getPlacePhoto (fetch stubbed) ──────────────────────────────────────────── describe('getPlacePhoto (fetch stubbed)', () => { - it('MAPS-042: returns Wikimedia photo for coordinate-based lookup (no API key)', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } }, - }), - })); + it('MAPS-042: returns proxy URL for coordinate-based lookup via Wikimedia (no API key)', async () => { + vi.stubGlobal('fetch', vi.fn() + // First call: Wikimedia Commons API + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } }, + }), + }) + // Second call: fetch Wikimedia image bytes + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(100), + }) + ); const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const result = await getPlacePhoto(999, 'coords:48.8,2.3', 48.8, 2.3, 'Eiffel Tower'); - expect(result.photoUrl).toBe('https://wiki.org/photo.jpg'); + const placeId = 'coords:48.8,2.3'; + const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Eiffel Tower'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`); + expect(mockCachePut).toHaveBeenCalledOnce(); }); it('MAPS-043: throws 404 when Wikimedia returns nothing and no API key', async () => { @@ -1146,37 +1190,28 @@ describe('getPlacePhoto (fetch stubbed)', () => { await expect(getPlacePhoto(999, 'coords:0.0,0.0', 0, 0)).rejects.toMatchObject({ status: 404 }); }); - it('MAPS-043b: returns cached photo when cache entry is fresh and valid', async () => { - // First call populates cache; second call should use cache without fetching - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/cached.jpg' } } } }, - }), - })); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const uniqueId = `coords:cache-test-${Date.now()}`; - const first = await getPlacePhoto(999, uniqueId, 48.8, 2.3, 'Cache Test'); + it('MAPS-043b: returns cached photo when disk cache returns a hit', async () => { + const placeId = `coords:cache-test-${Date.now()}`; + const cachedUrl = `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`; + mockCacheGet.mockReturnValue({ + photoUrl: cachedUrl, + filePath: `/tmp/${placeId}.jpg`, + attribution: null, + }); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); - const second = await getPlacePhoto(999, uniqueId, 48.8, 2.3, 'Cache Test'); - expect(second.photoUrl).toBe(first.photoUrl); + const { getPlacePhoto } = await import('../../../src/services/mapsService'); + const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Cache Test'); + expect(result.photoUrl).toBe(cachedUrl); expect(fetchMock).not.toHaveBeenCalled(); }); - it('MAPS-043c: throws 404 from cache when cached entry is an error', async () => { - // Seed the cache with an error entry by triggering a no-result Wikimedia call - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ query: { pages: {} } }), - })); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const errorId = `coords:error-cache-${Date.now()}`; - // First call causes error to be cached - await expect(getPlacePhoto(999, errorId, 0, 0)).rejects.toMatchObject({ status: 404 }); - // Second call should throw directly from cache (no fetch) + it('MAPS-043c: throws 404 from error cache without making a network request', async () => { + mockCacheGetErrored.mockReturnValue(true); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); + const { getPlacePhoto } = await import('../../../src/services/mapsService'); + const errorId = `coords:error-cache-${Date.now()}`; await expect(getPlacePhoto(999, errorId, 0, 0)).rejects.toMatchObject({ status: 404 }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -1194,7 +1229,7 @@ describe('getPlacePhoto (fetch stubbed)', () => { await expect(getPlacePhoto(999, throwId, 48.8, 2.3, 'Place')).rejects.toMatchObject({ status: 404 }); }); - it('MAPS-044: returns photo via Google path when API key present and photos exist', async () => { + it('MAPS-044: returns proxy URL via Google path when API key present and photos exist', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); const fetchMock = vi.fn() // First call: get place details (with photos) @@ -1204,17 +1239,18 @@ describe('getPlacePhoto (fetch stubbed)', () => { photos: [{ name: 'places/ChIJABC/photos/photo1', authorAttributions: [{ displayName: 'Photographer' }] }], }), }) - // Second call: get media URL + // Second call: fetch image bytes .mockResolvedValueOnce({ ok: true, - json: async () => ({ photoUri: 'https://lh3.googleusercontent.com/photo.jpg' }), + arrayBuffer: async () => new ArrayBuffer(200), }); vi.stubGlobal('fetch', fetchMock); const { getPlacePhoto } = await import('../../../src/services/mapsService'); const uniqueId = `ChIJABC-${Date.now()}`; const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Place'); - expect(result.photoUrl).toBe('https://lh3.googleusercontent.com/photo.jpg'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`); expect(result.attribution).toBe('Photographer'); + expect(mockCachePut).toHaveBeenCalledOnce(); }); it('MAPS-044b: throws 404 when Google details fetch returns non-ok', async () => { @@ -1240,7 +1276,7 @@ describe('getPlacePhoto (fetch stubbed)', () => { await expect(getPlacePhoto(1, noPhotoId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 }); }); - it('MAPS-044d: throws 404 when media endpoint returns no photoUri', async () => { + it('MAPS-044d: throws 404 when media endpoint returns non-ok status', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); const fetchMock = vi.fn() .mockResolvedValueOnce({ @@ -1250,8 +1286,9 @@ describe('getPlacePhoto (fetch stubbed)', () => { }), }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({}), // no photoUri + ok: false, + status: 403, + arrayBuffer: async () => new ArrayBuffer(0), }); vi.stubGlobal('fetch', fetchMock); const { getPlacePhoto } = await import('../../../src/services/mapsService'); @@ -1259,7 +1296,7 @@ describe('getPlacePhoto (fetch stubbed)', () => { await expect(getPlacePhoto(1, noUriId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 }); }); - it('MAPS-044e: returns photo with null attribution when authorAttributions is empty', async () => { + it('MAPS-044e: returns proxy URL with null attribution when authorAttributions is empty', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); const fetchMock = vi.fn() .mockResolvedValueOnce({ @@ -1270,28 +1307,34 @@ describe('getPlacePhoto (fetch stubbed)', () => { }) .mockResolvedValueOnce({ ok: true, - json: async () => ({ photoUri: 'https://lh3.googleusercontent.com/noattr.jpg' }), + arrayBuffer: async () => new ArrayBuffer(150), }); vi.stubGlobal('fetch', fetchMock); const { getPlacePhoto } = await import('../../../src/services/mapsService'); const noAttrId = `ChIJNoAttr-${Date.now()}`; const result = await getPlacePhoto(1, noAttrId, 48.8, 2.3); - expect(result.photoUrl).toBe('https://lh3.googleusercontent.com/noattr.jpg'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(noAttrId)}/bytes`); expect(result.attribution).toBeNull(); }); - it('MAPS-044f: uses Wikimedia when API key present but placeId is coords: prefix', async () => { + it('MAPS-044f: uses Wikimedia and returns proxy URL when API key present but placeId is coords: prefix', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } }, - }), - })); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(120), + }) + ); const { getPlacePhoto } = await import('../../../src/services/mapsService'); - // Use a unique placeId to avoid hitting the in-memory cache from other tests const uniqueId = `coords:44f-test-${Date.now()}`; const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Coords Place'); - expect(result.photoUrl).toBe('https://wiki.org/coords-photo.jpg'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`); + expect(mockCachePut).toHaveBeenCalledOnce(); }); }); From 8a58ce51c06ac7740cb51d173501cf6a8bd434ae Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 17 Apr 2026 19:28:40 +0200 Subject: [PATCH 08/16] feat(maps): add kill switches for Google Places autocomplete and details Add admin toggles for places_autocomplete_enabled and places_details_enabled alongside the existing places_photos_enabled, all default ON. - adminService: getPlacesAutocomplete/updatePlacesAutocomplete, getPlacesDetails/updatePlacesDetails - admin routes: GET/PUT /admin/places-autocomplete, /admin/places-details - maps routes: autocomplete returns { suggestions: [], source: 'disabled' } when off; details returns { place: null, disabled: true } when off - authService: both flags included in getAppConfig() response - authStore: placesAutocompleteEnabled + placesDetailsEnabled state and setters - App.tsx: wire both flags from app-config on load - AdminPage: two new toggle rows using var(--text-primary)/var(--border-primary) consistent with rest of UI - i18n: all 15 locales (en, de, ar, br, cs, es, fr, hu, id, it, nl, pl, ru, zh, zhTw) --- client/src/App.tsx | 6 ++- client/src/api/client.ts | 4 ++ client/src/i18n/translations/ar.ts | 6 +++ client/src/i18n/translations/br.ts | 6 +++ client/src/i18n/translations/cs.ts | 6 +++ client/src/i18n/translations/de.ts | 4 ++ client/src/i18n/translations/en.ts | 4 ++ client/src/i18n/translations/es.ts | 6 +++ client/src/i18n/translations/fr.ts | 6 +++ client/src/i18n/translations/hu.ts | 6 +++ client/src/i18n/translations/id.ts | 6 +++ client/src/i18n/translations/it.ts | 6 +++ client/src/i18n/translations/nl.ts | 6 +++ client/src/i18n/translations/pl.ts | 6 +++ client/src/i18n/translations/ru.ts | 6 +++ client/src/i18n/translations/zh.ts | 6 +++ client/src/i18n/translations/zhTw.ts | 6 +++ client/src/pages/AdminPage.tsx | 55 ++++++++++++++++++++++++++-- client/src/store/authStore.ts | 8 ++++ server/src/routes/admin.ts | 38 +++++++++++++++++++ server/src/routes/maps.ts | 6 +++ server/src/services/adminService.ts | 24 ++++++++++++ server/src/services/authService.ts | 6 +++ 23 files changed, 228 insertions(+), 5 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 9d00169d..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, setPlacesPhotosEnabled } = 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; places_photos_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) @@ -126,6 +126,8 @@ export default function App() { 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 b4461730..179e02ff 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -274,6 +274,10 @@ export const adminApi = { 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), diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index e4a7ef49..218536cc 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -588,6 +588,12 @@ const ar: Record = { 'admin.fileTypesSaved': 'تم حفظ إعدادات أنواع الملفات', // Packing Templates & Bag Tracking + 'admin.placesPhotos.title': 'صور الأماكن', + 'admin.placesPhotos.subtitle': 'جلب الصور من Google Places API. عطّلها للحفاظ على حصة API. صور Wikimedia غير متأثرة.', + 'admin.placesAutocomplete.title': 'الإكمال التلقائي للأماكن', + 'admin.placesAutocomplete.subtitle': 'استخدام Google Places API لاقتراحات البحث. عطّلها للحفاظ على حصة API.', + 'admin.placesDetails.title': 'تفاصيل الأماكن', + 'admin.placesDetails.subtitle': 'جلب معلومات تفصيلية عن الأماكن (الساعات، التقييم، الموقع) من Google Places API. عطّلها للحفاظ على حصة API.', 'admin.bagTracking.title': 'تتبع الأمتعة', 'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر', 'admin.collab.chat.title': 'الدردشة', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 8c4d0c4f..9264943f 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -546,6 +546,12 @@ const br: Record = { 'admin.fileTypesSaved': 'Configurações de tipos de arquivo salvas', // Packing Templates & Bag Tracking + 'admin.placesPhotos.title': 'Fotos de Locais', + 'admin.placesPhotos.subtitle': 'Busca fotos da Google Places API. Desative para economizar cota da API. Fotos do Wikimedia não são afetadas.', + 'admin.placesAutocomplete.title': 'Autocompletar de Locais', + 'admin.placesAutocomplete.subtitle': 'Usa a Google Places API para sugestões de pesquisa. Desative para economizar cota da API.', + 'admin.placesDetails.title': 'Detalhes do Local', + 'admin.placesDetails.subtitle': 'Busca informações detalhadas do local (horários, avaliação, site) da Google Places API. Desative para economizar cota da API.', 'admin.bagTracking.title': 'Rastreamento de malas', 'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index a7185359..7aca6d21 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -546,6 +546,12 @@ const cs: Record = { 'admin.fileTypesSaved': 'Nastavení souborů uloženo', // Šablony balení (Packing Templates) + 'admin.placesPhotos.title': 'Fotografie míst', + 'admin.placesPhotos.subtitle': 'Načítání fotografií z Google Places API. Zakázáním ušetříte kvótu API. Fotografie z Wikimedia nejsou ovlivněny.', + 'admin.placesAutocomplete.title': 'Automatické doplňování míst', + 'admin.placesAutocomplete.subtitle': 'Použití Google Places API pro návrhy vyhledávání. Zakázáním ušetříte kvótu API.', + 'admin.placesDetails.title': 'Podrobnosti o místě', + 'admin.placesDetails.subtitle': 'Načítání podrobných informací o místě (hodiny, hodnocení, web) z Google Places API. Zakázáním ušetříte kvótu API.', 'admin.bagTracking.title': 'Sledování zavazadel', 'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 7adecba1..c0e65fde 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -551,6 +551,10 @@ const de: Record = { 'admin.placesPhotos.title': 'Ortsfotos', 'admin.placesPhotos.subtitle': 'Fotos von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen. Wikimedia-Fotos sind davon nicht betroffen.', + 'admin.placesAutocomplete.title': 'Orts-Autovervollständigung', + 'admin.placesAutocomplete.subtitle': 'Google Places API für Suchvorschläge nutzen. Deaktivieren, um API-Kontingent zu sparen.', + 'admin.placesDetails.title': 'Ortsdetails', + 'admin.placesDetails.subtitle': 'Detaillierte Ortsinformationen (Öffnungszeiten, Bewertung, Website) von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen.', // Packing Templates & Bag Tracking 'admin.bagTracking.title': 'Gepäck-Tracking', 'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 7cdfccda..fcf3eedf 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -611,6 +611,10 @@ const en: Record = { 'admin.placesPhotos.title': 'Place Photos', 'admin.placesPhotos.subtitle': 'Fetch photos from the Google Places API. Disable to save API quota. Wikimedia photos are unaffected.', + 'admin.placesAutocomplete.title': 'Place Autocomplete', + 'admin.placesAutocomplete.subtitle': 'Use the Google Places API for search suggestions. Disable to save API quota.', + 'admin.placesDetails.title': 'Place Details', + 'admin.placesDetails.subtitle': 'Fetch detailed place information (hours, rating, website) from the Google Places API. Disable to save API quota.', // Packing Templates & Bag Tracking 'admin.bagTracking.title': 'Bag Tracking', 'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 13b5854f..84dd3c9d 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -541,6 +541,12 @@ const es: Record = { 'admin.fileTypesFormat': 'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.', 'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados', + 'admin.placesPhotos.title': 'Fotos de Lugares', + 'admin.placesPhotos.subtitle': 'Obtiene fotos de la Google Places API. Desactiva para ahorrar cuota de API. Las fotos de Wikimedia no se ven afectadas.', + 'admin.placesAutocomplete.title': 'Autocompletado de Lugares', + 'admin.placesAutocomplete.subtitle': 'Usa la Google Places API para sugerencias de búsqueda. Desactiva para ahorrar cuota de API.', + 'admin.placesDetails.title': 'Detalles del Lugar', + 'admin.placesDetails.subtitle': 'Obtiene información detallada del lugar (horarios, valoración, web) de la Google Places API. Desactiva para ahorrar cuota de API.', 'admin.bagTracking.title': 'Seguimiento de equipaje', 'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 2a1d6fcc..436bd107 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -545,6 +545,12 @@ const fr: Record = { 'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.', 'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés', + 'admin.placesPhotos.title': 'Photos de lieux', + 'admin.placesPhotos.subtitle': "Récupère les photos depuis l'API Google Places. Désactivez pour économiser le quota API. Les photos Wikimedia ne sont pas affectées.", + 'admin.placesAutocomplete.title': 'Autocomplétion des lieux', + 'admin.placesAutocomplete.subtitle': "Utilise l'API Google Places pour les suggestions de recherche. Désactivez pour économiser le quota API.", + 'admin.placesDetails.title': 'Détails du lieu', + 'admin.placesDetails.subtitle': "Récupère les informations détaillées du lieu (horaires, note, site web) depuis l'API Google Places. Désactivez pour économiser le quota API.", 'admin.bagTracking.title': 'Suivi des bagages', 'admin.bagTracking.subtitle': 'Activer le poids et l\'attribution de bagages pour les articles', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 462186f0..e3b19545 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -546,6 +546,12 @@ const hu: Record = { 'admin.fileTypesSaved': 'Fájltípus-beállítások mentve', // Csomagolási sablonok és poggyászkövetés + 'admin.placesPhotos.title': 'Helyfotók', + 'admin.placesPhotos.subtitle': 'Fotók lekérése a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához. A Wikimedia-fotók nem érintettek.', + 'admin.placesAutocomplete.title': 'Hely automatikus kiegészítése', + 'admin.placesAutocomplete.subtitle': 'A Google Places API használata keresési javaslatokhoz. Tiltsa le az API-kvóta megtakarításához.', + 'admin.placesDetails.title': 'Hely részletei', + 'admin.placesDetails.subtitle': 'Részletes helyinformációk lekérése (nyitvatartás, értékelés, weboldal) a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához.', 'admin.bagTracking.title': 'Poggyászkövetés', 'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 5e7a0f1e..bdba0cf8 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -610,6 +610,12 @@ const id: Record = { 'admin.fileTypesSaved': 'Pengaturan jenis file disimpan', // Packing Templates & Bag Tracking + 'admin.placesPhotos.title': 'Foto Tempat', + 'admin.placesPhotos.subtitle': 'Mengambil foto dari Google Places API. Nonaktifkan untuk menghemat kuota API. Foto Wikimedia tidak terpengaruh.', + 'admin.placesAutocomplete.title': 'Pelengkapan Otomatis Tempat', + 'admin.placesAutocomplete.subtitle': 'Menggunakan Google Places API untuk saran pencarian. Nonaktifkan untuk menghemat kuota API.', + 'admin.placesDetails.title': 'Detail Tempat', + 'admin.placesDetails.subtitle': 'Mengambil informasi detail tempat (jam, penilaian, situs web) dari Google Places API. Nonaktifkan untuk menghemat kuota API.', 'admin.bagTracking.title': 'Pelacak Tas', 'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 58ecfea6..178305e5 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -545,6 +545,12 @@ const it: Record = { 'admin.fileTypesFormat': 'Estensioni separate da virgola (es. jpg,png,pdf,doc). Usa * per consentire tutti i tipi.', 'admin.fileTypesSaved': 'Impostazioni dei tipi di file salvate', // Packing Templates & Bag Tracking + 'admin.placesPhotos.title': 'Foto dei luoghi', + 'admin.placesPhotos.subtitle': "Recupera le foto dall'API Google Places. Disabilita per risparmiare la quota API. Le foto di Wikimedia non sono interessate.", + 'admin.placesAutocomplete.title': 'Completamento automatico dei luoghi', + 'admin.placesAutocomplete.subtitle': "Utilizza l'API Google Places per i suggerimenti di ricerca. Disabilita per risparmiare la quota API.", + 'admin.placesDetails.title': 'Dettagli del luogo', + 'admin.placesDetails.subtitle': "Recupera informazioni dettagliate sul luogo (orari, valutazione, sito web) dall'API Google Places. Disabilita per risparmiare la quota API.", 'admin.bagTracking.title': 'Tracciamento valigia', 'admin.bagTracking.subtitle': 'Abilita il peso e l\'assegnazione della valigia per gli elementi della lista valigia', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index cc9d97cd..17a7165c 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -546,6 +546,12 @@ const nl: Record = { 'admin.fileTypesFormat': 'Kommagescheiden extensies (bijv. jpg,png,pdf,doc). Gebruik * om alle typen toe te staan.', 'admin.fileTypesSaved': 'Bestandstype-instellingen opgeslagen', + 'admin.placesPhotos.title': "Plaatsfoto's", + 'admin.placesPhotos.subtitle': "Haalt foto's op via de Google Places API. Schakel uit om API-quota te besparen. Wikimedia-foto's worden niet beïnvloed.", + 'admin.placesAutocomplete.title': 'Plaatsautocomplete', + 'admin.placesAutocomplete.subtitle': 'Gebruikt de Google Places API voor zoeksuggesties. Schakel uit om API-quota te besparen.', + 'admin.placesDetails.title': 'Plaatsdetails', + 'admin.placesDetails.subtitle': 'Haalt gedetailleerde plaatsinformatie (openingstijden, beoordeling, website) op via de Google Places API. Schakel uit om API-quota te besparen.', 'admin.bagTracking.title': 'Bagagetracking', 'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 427a2134..080d5cb3 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -518,6 +518,12 @@ const pl: Record = { 'admin.fileTypesSaved': 'Ustawienia typów plików zostały zapisane', // Packing Templates & Bag Tracking + 'admin.placesPhotos.title': 'Zdjęcia miejsc', + 'admin.placesPhotos.subtitle': 'Pobiera zdjęcia z Google Places API. Wyłącz, aby zaoszczędzić limit API. Zdjęcia z Wikimedia nie są objęte.', + 'admin.placesAutocomplete.title': 'Autouzupełnianie miejsc', + 'admin.placesAutocomplete.subtitle': 'Używa Google Places API do sugestii wyszukiwania. Wyłącz, aby zaoszczędzić limit API.', + 'admin.placesDetails.title': 'Szczegóły miejsca', + 'admin.placesDetails.subtitle': 'Pobiera szczegółowe informacje o miejscu (godziny, ocena, strona) z Google Places API. Wyłącz, aby zaoszczędzić limit API.', 'admin.bagTracking.title': 'Kontrola bagażu', 'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania', 'admin.collab.chat.title': 'Czat', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 0bf70d93..9961de3d 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -546,6 +546,12 @@ const ru: Record = { 'admin.fileTypesFormat': 'Расширения через запятую (напр. jpg,png,pdf,doc). Используйте * для разрешения всех типов.', 'admin.fileTypesSaved': 'Настройки типов файлов сохранены', + 'admin.placesPhotos.title': 'Фотографии мест', + 'admin.placesPhotos.subtitle': 'Загрузка фотографий из Google Places API. Отключите для экономии квоты API. Фотографии Wikimedia не затронуты.', + 'admin.placesAutocomplete.title': 'Автодополнение мест', + 'admin.placesAutocomplete.subtitle': 'Использование Google Places API для поисковых подсказок. Отключите для экономии квоты API.', + 'admin.placesDetails.title': 'Сведения о месте', + 'admin.placesDetails.subtitle': 'Загрузка подробной информации о месте (часы работы, рейтинг, веб-сайт) из Google Places API. Отключите для экономии квоты API.', 'admin.bagTracking.title': 'Отслеживание багажа', 'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей', 'admin.collab.chat.title': 'Чат', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 60aacb85..98d5b4db 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -546,6 +546,12 @@ const zh: Record = { 'admin.fileTypesFormat': '以逗号分隔的扩展名(如 jpg,png,pdf,doc)。使用 * 允许所有类型。', 'admin.fileTypesSaved': '文件类型设置已保存', + 'admin.placesPhotos.title': '地点照片', + 'admin.placesPhotos.subtitle': '从 Google Places API 获取照片。禁用可节省 API 配额。Wikimedia 照片不受影响。', + 'admin.placesAutocomplete.title': '地点自动补全', + 'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜索建议。禁用可节省 API 配额。', + 'admin.placesDetails.title': '地点详情', + 'admin.placesDetails.subtitle': '从 Google Places API 获取地点详细信息(营业时间、评分、网站)。禁用可节省 API 配额。', 'admin.bagTracking.title': '行李追踪', 'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配', 'admin.collab.chat.title': '聊天', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 50a0f6b1..9d83ae40 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -606,6 +606,12 @@ const zhTw: Record = { 'admin.fileTypesFormat': '以逗號分隔的副檔名(如 jpg,png,pdf,doc)。使用 * 允許所有型別。', 'admin.fileTypesSaved': '檔案型別設定已儲存', + 'admin.placesPhotos.title': '地點照片', + 'admin.placesPhotos.subtitle': '從 Google Places API 獲取照片。停用可節省 API 配額。Wikimedia 照片不受影響。', + 'admin.placesAutocomplete.title': '地點自動補全', + 'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜尋建議。停用可節省 API 配額。', + 'admin.placesDetails.title': '地點詳情', + 'admin.placesDetails.subtitle': '從 Google Places API 獲取地點詳細資訊(營業時間、評分、網站)。停用可節省 API 配額。', 'admin.bagTracking.title': '行李追蹤', 'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配', 'admin.collab.chat.title': '聊天', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 0a4d876c..9b976734 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -198,6 +198,14 @@ export default function AdminPage(): React.ReactElement { const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState(true) useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, []) + // Places autocomplete + const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState(true) + useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, []) + + // Places details + const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState(true) + useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, []) + // Collab features const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true }) useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, []) @@ -246,7 +254,7 @@ export default function AdminPage(): React.ReactElement { const [updateInfo, setUpdateInfo] = useState(null) const [showUpdateModal, setShowUpdateModal] = useState(false) - const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, logout } = useAuthStore() + const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore() const navigate = useNavigate() const toast = useToast() @@ -1040,9 +1048,50 @@ export default function AdminPage(): React.ReactElement { setPlacesPhotosEnabled(next) try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) } }} - className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${placesPhotosEnabled ? 'bg-indigo-600' : 'bg-slate-200'}`} + className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors" + style={{ background: placesPhotosEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }} > - + + +
+ + {/* Place Autocomplete Toggle */} +
+
+

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

+

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

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

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

+

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

+
+
diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 6c6efbf9..8d8c342d 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -34,6 +34,8 @@ interface AuthState { appRequireMfa: boolean tripRemindersEnabled: boolean placesPhotosEnabled: boolean + placesAutocompleteEnabled: boolean + placesDetailsEnabled: boolean login: (email: string, password: string) => Promise completeMfaLogin: (mfaToken: string, code: string) => Promise @@ -55,6 +57,8 @@ interface AuthState { setAppRequireMfa: (val: boolean) => void setTripRemindersEnabled: (val: boolean) => void setPlacesPhotosEnabled: (val: boolean) => void + setPlacesAutocompleteEnabled: (val: boolean) => void + setPlacesDetailsEnabled: (val: boolean) => void demoLogin: () => Promise } @@ -77,6 +81,8 @@ export const useAuthStore = create()( appRequireMfa: false, tripRemindersEnabled: false, placesPhotosEnabled: true, + placesAutocompleteEnabled: true, + placesDetailsEnabled: true, login: async (email: string, password: string) => { authSequence++ @@ -261,6 +267,8 @@ export const useAuthStore = create()( setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }), setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }), setPlacesPhotosEnabled: (val: boolean) => set({ placesPhotosEnabled: val }), + setPlacesAutocompleteEnabled: (val: boolean) => set({ placesAutocompleteEnabled: val }), + setPlacesDetailsEnabled: (val: boolean) => set({ placesDetailsEnabled: val }), demoLogin: async () => { authSequence++ diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 58cf4749..1c9c2aca 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -220,6 +220,44 @@ router.put('/places-photos', (req: Request, res: Response) => { res.json(result); }); +// ── Places Autocomplete ────────────────────────────────────────────────── + +router.get('/places-autocomplete', (_req: Request, res: Response) => { + res.json(svc.getPlacesAutocomplete()); +}); + +router.put('/places-autocomplete', (req: Request, res: Response) => { + if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' }); + const result = svc.updatePlacesAutocomplete(req.body.enabled); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.places_autocomplete', + ip: getClientIp(req), + details: { enabled: result.enabled }, + }); + res.json(result); +}); + +// ── Places Details ─────────────────────────────────────────────────────── + +router.get('/places-details', (_req: Request, res: Response) => { + res.json(svc.getPlacesDetails()); +}); + +router.put('/places-details', (req: Request, res: Response) => { + if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' }); + const result = svc.updatePlacesDetails(req.body.enabled); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.places_details', + ip: getClientIp(req), + details: { enabled: result.enabled }, + }); + res.json(result); +}); + // ── Collab Features ─────────────────────────────────────────────────────── router.get('/collab-features', (_req: Request, res: Response) => { diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index 9692747f..427d4500 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -35,6 +35,9 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { // POST /autocomplete router.post('/autocomplete', authenticate, async (req: Request, res: Response) => { + const autocompleteEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined; + if (autocompleteEnabledRow?.value === 'false') return res.status(200).json({ suggestions: [], source: 'disabled' }); + const authReq = req as AuthRequest; const { input, lang, locationBias } = req.body; @@ -73,6 +76,9 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) = // GET /details/:placeId router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => { + const detailsEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined; + if (detailsEnabledRow?.value === 'false') return res.status(200).json({ place: null, disabled: true }); + const authReq = req as AuthRequest; const { placeId } = req.params; const expand = req.query.expand as string | undefined; diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 8f1a9344..66a2d349 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -471,6 +471,30 @@ export function updatePlacesPhotos(enabled: boolean) { return { enabled: !!enabled }; } +// ── Places Autocomplete ──────────────────────────────────────────────────── + +export function getPlacesAutocomplete() { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined; + return { enabled: row?.value !== 'false' }; +} + +export function updatePlacesAutocomplete(enabled: boolean) { + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(enabled ? 'true' : 'false'); + return { enabled: !!enabled }; +} + +// ── Places Details ───────────────────────────────────────────────────────── + +export function getPlacesDetails() { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined; + return { enabled: row?.value !== 'false' }; +} + +export function updatePlacesDetails(enabled: boolean) { + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(enabled ? 'true' : 'false'); + return { enabled: !!enabled }; +} + // ── Collab Features ─────────────────────────────────────────────────────── const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const; diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 31c71326..b091bf58 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -231,6 +231,10 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { const tripRemindersEnabled = tripReminderSetting !== 'false'; const placesPhotosSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined)?.value; const placesPhotosEnabled = placesPhotosSetting !== 'false'; + const placesAutocompleteSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined)?.value; + const placesAutocompleteEnabled = placesAutocompleteSetting !== 'false'; + const placesDetailsSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined)?.value; + const placesDetailsEnabled = placesDetailsSetting !== 'false'; const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get()); return { @@ -261,6 +265,8 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true }, trip_reminders_enabled: tripRemindersEnabled, places_photos_enabled: placesPhotosEnabled, + places_autocomplete_enabled: placesAutocompleteEnabled, + places_details_enabled: placesDetailsEnabled, permissions: authenticatedUser ? getAllPermissions() : undefined, dev_mode: process.env.NODE_ENV === 'development', }; From 8cd5aa0d234d3bf546516ce3f6c7080d6c495f32 Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 17 Apr 2026 19:48:12 +0200 Subject: [PATCH 09/16] fix(synology): correct multi-album passphrase assignment and stale trek_photos - ProviderPicker now tracks per-asset album passphrase in a Map; on confirm, assets are grouped by passphrase and submitted as separate batches so each asset receives its own album's passphrase instead of the last-selected one - getOrCreateTrekPhoto unconditionally overwrites the stored passphrase when a fresh one is supplied, allowing re-adds to heal a stuck bad passphrase - deleteTrekPhotoIfOrphan purges the trek_photos row for provider assets when no trip_photos or journey_photos reference it anymore; wired into removeTripPhoto, removeAlbumLink, and deletePhoto so remove + re-add is a clean slate - Three new integration tests: SYNO-090 (passphrase overwrite), SYNO-091 (orphan cleanup), SYNO-092 (remove + re-add restores correct passphrase) --- client/src/pages/JourneyDetailPage.tsx | 39 ++++++---- server/src/services/journeyService.ts | 3 +- .../services/memories/photoResolverService.ts | 15 +++- .../src/services/memories/unifiedService.ts | 12 +++- .../integration/memories-synology.test.ts | 71 +++++++++++++++++++ 5 files changed, 124 insertions(+), 16 deletions(-) diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index f7002877..398f74cd 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1042,7 +1042,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, passphrase) => { + onAdd={async (groups, entryId) => { let targetId = entryId if (!targetId) { try { @@ -1055,10 +1055,12 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres } catch { return } } let added = 0 - try { - const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds, undefined, passphrase) - 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() @@ -1532,7 +1534,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on trips: JourneyTrip[] existingAssetIds: Set onClose: () => void - onAdd: (assetIds: string[], entryId: number | null, passphrase?: string) => Promise + onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise }) { const { t } = useTranslation() const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip') @@ -1546,7 +1548,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) @@ -1638,8 +1640,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 }) } @@ -1801,9 +1807,9 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */} - {!isMobile && view === 'timeline' && ( -
+ {!isMobile && ( +
{sortedDates.length === 0 && (
@@ -469,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 */} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index d4786fa4..a59b5f06 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1673,6 +1673,15 @@ function runMigrations(db: Database.Database): void { ) `); }}, + // Migration 108: Disk cache metadata for remote-provider photo thumbnails (Immich / Synology) + () => db.exec(` + CREATE TABLE IF NOT EXISTS trek_photo_cache_meta ( + cache_key TEXT PRIMARY KEY, + content_type TEXT NOT NULL DEFAULT 'image/jpeg', + fetched_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at); + `), ]; if (currentVersion < migrations.length) { diff --git a/server/src/index.ts b/server/src/index.ts index 91417455..c84a0839 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -49,6 +49,7 @@ const server = app.listen(PORT, () => { scheduler.startVersionCheck(); scheduler.startDemoReset(); scheduler.startIdempotencyCleanup(); + scheduler.startTrekPhotoCacheCleanup(); const { startTokenCleanup } = require('./services/ephemeralTokens'); startTokenCleanup(); import('./websocket').then(({ setupWebSocket }) => { diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts index f04b0df7..b11dc11c 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -248,12 +248,36 @@ function startIdempotencyCleanup(): void { }, { timezone: tz }); } +// Trek photo cache cleanup: every 2 hours — evict disk files and DB rows past their 1h TTL +let trekPhotoCacheTask: ScheduledTask | null = null; + +function startTrekPhotoCacheCleanup(): void { + if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; } + + // Run once immediately on startup to evict any entries left over from a previous run + try { + const { sweepExpired } = require('./services/memories/trekPhotoCache'); + sweepExpired(); + } catch { /* cache dir may not exist yet — harmless */ } + + trekPhotoCacheTask = cron.schedule('0 */2 * * *', () => { + try { + const { sweepExpired } = require('./services/memories/trekPhotoCache'); + sweepExpired(); + } catch (err: unknown) { + const { logError: le } = require('./services/auditLog'); + le(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`); + } + }); +} + function stop(): void { if (currentTask) { currentTask.stop(); currentTask = null; } if (demoTask) { demoTask.stop(); demoTask = null; } if (reminderTask) { reminderTask.stop(); reminderTask = null; } if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; } if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; } + if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; } } -export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, loadSettings, saveSettings, VALID_INTERVALS }; +export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS }; diff --git a/server/src/services/memories/immichService.ts b/server/src/services/memories/immichService.ts index dd212d34..34c37bca 100644 --- a/server/src/services/memories/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -230,6 +230,30 @@ export async function getAssetInfo( } } +export async function fetchImmichThumbnailBytes( + userId: number, + assetId: string, + ownerUserId?: number +): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> { + const effectiveUserId = ownerUserId ?? userId; + const creds = getImmichCredentials(effectiveUserId); + if (!creds) return { error: 'Not found', status: 404 }; + + const url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`; + try { + const resp = await safeFetch(url, { + headers: { 'x-api-key': creds.immich_api_key }, + signal: AbortSignal.timeout(10000) as any, + }); + if (!resp.ok) return { error: 'Upstream error', status: resp.status }; + const contentType = resp.headers.get('content-type') || 'image/jpeg'; + const bytes = Buffer.from(await resp.arrayBuffer()); + return { bytes, contentType }; + } catch { + return { error: 'Proxy error', status: 502 }; + } +} + export async function streamImmichAsset( response: Response, userId: number, diff --git a/server/src/services/memories/photoResolverService.ts b/server/src/services/memories/photoResolverService.ts index 3af080e5..82d07243 100644 --- a/server/src/services/memories/photoResolverService.ts +++ b/server/src/services/memories/photoResolverService.ts @@ -3,11 +3,12 @@ import path from 'path'; import fs from 'fs'; import { db } from '../../db/database'; import type { TrekPhoto } from '../../types'; -import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichService'; -import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService'; +import { streamImmichAsset, fetchImmichThumbnailBytes, getAssetInfo as getImmichAssetInfo } from './immichService'; +import { streamSynologyAsset, fetchSynologyThumbnailBytes, getSynologyAssetInfo } from './synologyService'; import type { ServiceResult, AssetInfo } from './helpersService'; import { fail, success } from './helpersService'; import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; +import * as photoCache from './trekPhotoCache'; // ── Lookup / Register ──────────────────────────────────────────────────── @@ -57,6 +58,36 @@ export function resolveTrekPhoto(photoId: number): TrekPhoto | null { // ── Streaming ──────────────────────────────────────────────────────────── +async function streamCachedThumbnail( + res: Response, + photo: TrekPhoto, + fetchBytes: () => Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }>, + fallback: () => Promise, +): Promise { + const key = photoCache.cacheKey(photo.provider!, photo.asset_id!, 'thumbnail', photo.owner_id!); + + if (photoCache.serveFresh(res, key)) return; + + const existing = photoCache.getInFlight(key); + if (existing) { + const bytes = await existing; + if (bytes && photoCache.serveFresh(res, key)) return; + await fallback(); + return; + } + + const promise = fetchBytes().then(async result => { + if ('error' in result) return null; + await photoCache.put(key, result.bytes, result.contentType); + return result.bytes; + }); + photoCache.setInFlight(key, promise); + + const bytes = await promise; + if (bytes && photoCache.serveFresh(res, key)) return; + await fallback(); +} + export async function streamPhoto( res: Response, userId: number, @@ -84,11 +115,27 @@ export async function streamPhoto( return; } case 'immich': { + if (kind === 'thumbnail') { + await streamCachedThumbnail( + res, photo, + () => fetchImmichThumbnailBytes(userId, photo.asset_id!, photo.owner_id!), + () => streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!), + ); + return; + } await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!); return; } case 'synologyphotos': { const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined; + if (kind === 'thumbnail') { + await streamCachedThumbnail( + res, photo, + () => fetchSynologyThumbnailBytes(userId, photo.owner_id!, photo.asset_id!, passphrase), + () => streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase), + ); + return; + } await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase); return; } diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts index d493b960..f65a2013 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -604,6 +604,47 @@ export async function getSynologyAssetInfo(userId: number, photoId: string, targ return success(normalized); } +export async function fetchSynologyThumbnailBytes( + userId: number, + targetUserId: number, + photoId: string, + passphrase?: string, +): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> { + const parsedId = _splitPackedSynologyId(photoId); + if (!parsedId) return { error: 'Invalid photo ID format', status: 400 }; + + const synology_credentials = _getSynologyCredentials(targetUserId); + if (!synology_credentials.success) return { error: 'Credentials error', status: 500 }; + + const sid = await _getSynologySession(targetUserId); + if (!sid.success) return { error: 'Session error', status: 500 }; + if (!sid.data) return { error: 'Session ID missing', status: 500 }; + + const params = new URLSearchParams({ + api: 'SYNO.Foto.Thumbnail', + method: 'get', + version: '2', + mode: 'download', + id: parsedId.id, + type: 'unit', + size: 'sm', + cache_key: parsedId.cacheKey, + _sid: sid.data, + }); + if (passphrase) params.append('passphrase', passphrase); + + const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString()); + try { + const resp = await safeFetch(url); + if (!resp.ok) return { error: 'Upstream error', status: resp.status }; + const contentType = resp.headers.get('content-type') || 'image/jpeg'; + const bytes = Buffer.from(await resp.arrayBuffer()); + return { bytes, contentType }; + } catch { + return { error: 'Proxy error', status: 502 }; + } +} + export async function streamSynologyAsset( response: Response, userId: number, diff --git a/server/src/services/memories/trekPhotoCache.ts b/server/src/services/memories/trekPhotoCache.ts new file mode 100644 index 00000000..4ee45a80 --- /dev/null +++ b/server/src/services/memories/trekPhotoCache.ts @@ -0,0 +1,91 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import crypto from 'node:crypto'; +import { Response } from 'express'; +import { db } from '../../db/database'; + +const TREK_PHOTO_DIR = path.join(__dirname, '../../../uploads/photos/trek'); +export const CACHE_TTL = 60 * 60 * 1000; // 1 hour + +const inFlight = new Map>(); + +export function cacheKey(provider: string, assetId: string, kind: string, ownerId: number): string { + return crypto.createHash('sha1').update(`${provider}:${assetId}:${kind}:${ownerId}`).digest('hex'); +} + +function ensureDir(): void { + if (!fs.existsSync(TREK_PHOTO_DIR)) { + fs.mkdirSync(TREK_PHOTO_DIR, { recursive: true }); + } +} + +function cachedFilePath(key: string): string { + return path.join(TREK_PHOTO_DIR, `${key}.bin`); +} + +export function getFresh(key: string): { filePath: string; contentType: string } | null { + const row = db.prepare( + 'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?' + ).get(key) as { content_type: string; fetched_at: number } | undefined; + + if (!row) return null; + + if (Date.now() - row.fetched_at >= CACHE_TTL) { + db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key); + return null; + } + + const fp = cachedFilePath(key); + if (!fs.existsSync(fp)) { + db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key); + return null; + } + + return { filePath: fp, contentType: row.content_type }; +} + +export async function put(key: string, bytes: Buffer, contentType: string): Promise { + ensureDir(); + const fp = cachedFilePath(key); + const tmp = fp + '.tmp'; + + await fsPromises.writeFile(tmp, bytes); + await fsPromises.rename(tmp, fp); + + db.prepare( + 'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)' + ).run(key, contentType, Date.now()); +} + +export function serveFresh(res: Response, key: string): boolean { + const entry = getFresh(key); + if (!entry) return false; + + res.set('Content-Type', entry.contentType); + res.set('Cache-Control', 'public, max-age=3600'); + res.sendFile(entry.filePath); + return true; +} + +export function getInFlight(key: string): Promise | undefined { + return inFlight.get(key); +} + +export function setInFlight(key: string, promise: Promise): void { + inFlight.set(key, promise); + promise.finally(() => inFlight.delete(key)); +} + +export function sweepExpired(): void { + const cutoff = Date.now() - CACHE_TTL * 2; + const stale = db.prepare( + 'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?' + ).all(cutoff) as { cache_key: string }[]; + + for (const row of stale) { + db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(row.cache_key); + const fp = cachedFilePath(row.cache_key); + if (fs.existsSync(fp)) fs.unlinkSync(fp); + } +} From 677157de1d8c3b21f3ce08e01f15a730793a25e5 Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 17 Apr 2026 21:02:46 +0200 Subject: [PATCH 16/16] test(journey): fix getByText assertions broken by keep-mounted tab change Tabs are now always mounted (visibility toggled via hidden class), so the same entry title can appear in multiple tab views simultaneously. Replace getByText with getAllByText for presence checks; scope the FE-086 click target to the cursor-pointer container. --- client/src/pages/JourneyDetailPage.test.tsx | 29 ++++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index d3b005cd..8b93c175 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -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); }); }); @@ -615,7 +615,7 @@ describe('JourneyDetailPage', () => { render(); await waitFor(() => { - expect(screen.getByText('Venice Visit')).toBeInTheDocument(); + expect(screen.getAllByText('Venice Visit').length).toBeGreaterThanOrEqual(1); }); // Skeleton card shows "Add Entry" CTA @@ -655,10 +655,10 @@ describe('JourneyDetailPage', () => { 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(); }); }); @@ -1117,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); }); }); @@ -1177,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); }); }); @@ -1878,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)