Merge remote-tracking branch 'origin/dev' into dev-maurice

# Conflicts:
#	client/src/components/Todo/TodoListPanel.tsx
#	server/src/db/migrations.ts
This commit is contained in:
Maurice
2026-04-17 23:44:53 +02:00
71 changed files with 2259 additions and 515 deletions
+5 -2
View File
@@ -100,7 +100,7 @@ function RootRedirect() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
@@ -116,7 +116,7 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
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<string, PermissionLevel> }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
@@ -125,6 +125,9 @@ export default function App() {
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
if (config?.version) {
+8 -2
View File
@@ -272,6 +272,12 @@ export const adminApi = {
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
@@ -331,8 +337,8 @@ export const journeyApi = {
// Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
+1 -1
View File
@@ -94,7 +94,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
onTouchEnd={e => {
+6 -1
View File
@@ -14,6 +14,7 @@ export interface MapMarkerItem {
export interface JourneyMapHandle {
highlightMarker: (id: string | null) => void
focusMarker: (id: string) => void
invalidateSize: () => void
}
interface MapEntry {
@@ -151,7 +152,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
}
}, [])
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), [])
const invalidateSize = useCallback(() => {
try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ }
}, [])
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [])
useEffect(() => {
if (!containerRef.current) return
@@ -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}
+10 -10
View File
@@ -68,9 +68,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
">${label}</span>`
}
// Base64 data URL thumbnails — no external image fetch during zoom
// Only use base64 data URLs for markers — external URLs cause zoom lag
if (place.image_url && place.image_url.startsWith('data:')) {
// Prefer base64 data URLs (no zoom lag); also accept same-origin proxy URLs as a fallback
// while the thumb is still being generated in the background
if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/'))) {
const imgIcon = L.divIcon({
className: '',
html: `<div style="
@@ -277,6 +277,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
// Module-level photo cache shared with PlaceAvatar
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { useAuthStore } from '../../store/authStore'
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
function LocationTracker() {
@@ -409,20 +410,19 @@ export const MapView = memo(function MapView({
// photoUrls: only base64 thumbs for smooth map zoom
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
// Fetch photos via shared service — subscribe to thumb (base64) availability
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
useEffect(() => {
if (!places || places.length === 0) return
if (!places || places.length === 0 || !placesPhotosEnabled) return
const cleanups: (() => void)[] = []
const setThumb = (cacheKey: string, thumb: string) => {
iconCache.clear()
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
}
for (const place of places) {
if (place.image_url && place.image_url.startsWith('data:')) continue
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
if (!cacheKey) continue
@@ -435,9 +435,9 @@ export const MapView = memo(function MapView({
// Subscribe for when thumb becomes available
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
// Always fetch through API — returns fresh URL + converts to base64
if (!cached && !isLoading(cacheKey)) {
const photoId = place.google_place_id || place.osm_id
// Use the persisted proxy URL as photoId so photoService generates a base64 thumb from it
const photoId = place.image_url || place.google_place_id || place.osm_id
if (photoId || (place.lat && place.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
}
@@ -445,7 +445,7 @@ export const MapView = memo(function MapView({
}
return () => cleanups.forEach(fn => fn())
}, [placeIds])
}, [placeIds, placesPhotosEnabled])
const clusterIconCreateFunction = useCallback((cluster) => {
const count = cluster.getChildCount()
@@ -462,7 +462,7 @@ export const MapView = memo(function MapView({
const markers = useMemo(() => places.map((place) => {
const isSelected = place.id === selectedPlaceId
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
const resolvedPhoto = (pck && photoUrls[pck]) || place.image_url || null
const orderNumbers = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
+1 -1
View File
@@ -308,7 +308,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
iframe.srcdoc = html
card.appendChild(header)
+1 -1
View File
@@ -521,7 +521,7 @@ ${daysHtml}
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
iframe.srcdoc = html
card.appendChild(header)
@@ -1267,7 +1267,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0 }
{/* ── Bag Modal (mobile + click) ── */}
{showBagModal && bagTrackingEnabled && (
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, overflowY: 'auto' }}
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
onClick={() => setShowBagModal(false)}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
onClick={e => e.stopPropagation()}>
@@ -79,6 +79,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
return (
<div
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
{/* Main area */}
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { flushSync } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
@@ -70,18 +71,20 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
: DefaultIcon;
return (
<div className="flex flex-col relative">
<div className="flex flex-col relative" style={{ flex: '1 1 0', minHeight: '100%' }}>
{/* Dismiss X button — only on last page so users read all notices */}
{notice.dismissible && isLastPage && (
<button
onClick={onDismissAll}
className="absolute top-4 right-4 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
className="absolute top-4 right-4 z-10 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="Dismiss"
>
<X size={18} />
</button>
)}
{/* Scrollable content — vertically centered when shorter than available space */}
<div className="flex flex-col justify-center" style={{ flex: '1 1 0' }}>
{/* Hero image (not inline) */}
{notice.media && notice.media.placement !== 'inline' && (
<div
@@ -115,7 +118,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
</div>
)}
<div className={notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'}>
<div className={`${notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'} flex flex-col`}>
{/* Severity icon (when no hero and not Heart) */}
{!notice.media && notice.icon !== 'Heart' && (
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
@@ -133,7 +136,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
</h2>
)}
{/* Body — markdown (long body text uses left-aligned layout) */}
{/* Body — markdown */}
<div
id={bodyId}
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
@@ -213,16 +216,23 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
})}
</ul>
)}
</div>
</div>
{/* Sticky footer — pager + CTA, always anchored at the bottom of the slot */}
<div
className="sticky bottom-0 px-8 pt-4 flex flex-col gap-3 bg-white dark:bg-slate-900 border-t border-slate-100 dark:border-slate-800"
style={{ paddingBottom: 'calc(var(--bottom-nav-h) + 1rem)' }}
>
{/* Pager — dots, arrows, counter (only when multiple notices) */}
{total > 1 && (
<div className="flex flex-col items-center gap-1 mb-4">
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2">
<button
onClick={onPrev}
disabled={!canPage || currentPage === 0}
aria-label={t('system_notice.pager.prev')}
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={14} />
</button>
@@ -246,7 +256,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
onClick={onNext}
disabled={!canPage || currentPage === total - 1}
aria-label={t('system_notice.pager.next')}
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={14} />
</button>
@@ -261,17 +271,8 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
)}
{/* CTA + dismiss link */}
<div className="flex flex-col items-center gap-3 mt-2">
{!isLastPage && total > 1 ? (
/* Non-last page: "Next" button to advance through all notices */
<button
id={`notice-cta-${notice.id}`}
onClick={onNext}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors flex items-center justify-center gap-2"
>
{t('system_notice.pager.next')} <ChevronRight size={16} />
</button>
) : ctaLabel ? (
<div className="flex flex-col items-center gap-3">
{ctaLabel && isLastPage ? (
<button
id={`notice-cta-${notice.id}`}
onClick={onCTA}
@@ -279,10 +280,10 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
>
{ctaLabel}
</button>
) : (
) : (notice.dismissible || isLastPage) && (
<button
id={`notice-cta-${notice.id}`}
onClick={onDismissAll}
onClick={isLastPage ? onDismissAll : onNext}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
>
{t('common.ok')}
@@ -327,7 +328,13 @@ export function ModalRenderer({ notices }: Props) {
// Non-dismissible notices lock the pager so users must act before advancing.
const canPage = notice?.dismissible !== false;
const touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
// 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified
const dragLockRef = useRef<'h' | 'v' | null>(null);
// Sheet scroll offset at the moment the touch began — used to suppress dismiss-drag
// when the user is scrolled into content and pans down to scroll back up.
const scrollTopAtTouchStart = useRef(0);
// Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
const noticeIdRef = useRef<string | null>(null);
noticeIdRef.current = notice?.id ?? null;
@@ -339,7 +346,15 @@ export function ModalRenderer({ notices }: Props) {
// contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly.
const isPageNavRef = useRef(false);
const slideDirRef = useRef<'left' | 'right'>('right');
const contentWrapperRef = useRef<HTMLDivElement>(null);
// Mobile drag strip — wraps all 3 slots and is translated to reveal prev/current/next
const stripRef = useRef<HTMLDivElement>(null);
// The sheet element itself — animated on vertical drag-to-dismiss
const sheetRef = useRef<HTMLDivElement>(null);
const clipRef = useRef<HTMLDivElement>(null);
// Individual slot scroll containers (prev / center / next)
const prevSlotRef = useRef<HTMLDivElement>(null);
const contentWrapperRef = useRef<HTMLDivElement>(null); // center slot
const nextSlotRef = useRef<HTMLDivElement>(null);
// Mobile breakpoint
useEffect(() => {
@@ -455,6 +470,12 @@ export function ModalRenderer({ notices }: Props) {
return () => { document.body.style.overflow = ''; };
}, [visible, notice]);
// Reset center slot scroll to top on navigation (keyboard / pager buttons).
useEffect(() => {
if (!isMobile) return;
contentWrapperRef.current?.scrollTo({ top: 0 });
}, [idx, isMobile]);
function announceIndex(newIdx: number, total: number) {
setPageAnnouncement(
t('system_notice.pager.position')
@@ -498,6 +519,17 @@ export function ModalRenderer({ notices }: Props) {
}
}
function animatedDismissAll() {
const sheet = sheetRef.current;
if (!sheet || prefersReducedMotion) { handleDismissAll(); return; }
sheet.style.transition = 'transform 300ms ease-out';
sheet.style.transform = 'translateY(110%)';
sheet.addEventListener('transitionend', function onDone() {
sheet.removeEventListener('transitionend', onDone);
handleDismissAll();
}, { once: true });
}
// Sets up the content wrapper's start transform SYNCHRONOUSLY (before React
// re-renders with the new notice), then flags the grace-delay effect to slide
// rather than hide+show.
@@ -576,6 +608,38 @@ export function ModalRenderer({ notices }: Props) {
? (visible ? 'opacity-100' : 'opacity-0')
: (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full');
// Build ContentProps for an adjacent slot so NoticeContent renders correctly
function buildSlotProps(n: SystemNoticeDTO, slotIdx: number): ContentProps {
const slotRawBody = t(n.bodyKey);
const slotBody = n.bodyParams
? Object.entries(n.bodyParams).reduce(
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
slotRawBody
)
: slotRawBody;
return {
notice: n,
title: t(n.titleKey),
body: slotBody,
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
titleId: `notice-title-${n.id}`,
bodyId: `notice-body-${n.id}`,
isDark,
onDismiss: handleDismiss,
onDismissAll: handleDismissAll,
onCTA: handleCTA,
total: notices.length,
currentPage: slotIdx,
canPage,
onPrev: handlePrev,
onNext: handleNext,
onGoto: handleGoto,
};
}
const prevNotice = notices[idx - 1] ?? null;
const nextNotice = notices[idx + 1] ?? null;
return (
<div className="fixed inset-0 z-50" role="presentation">
{/* Screen-reader page announcements */}
@@ -583,31 +647,151 @@ export function ModalRenderer({ notices }: Props) {
{/* Backdrop */}
<div
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
onClick={notice.dismissible ? handleDismiss : undefined}
onClick={notice.dismissible ? animatedDismissAll : undefined}
/>
{/* Bottom sheet */}
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden max-h-[85dvh] overflow-y-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-all ${dur} ${ease} ${mobileMotion}`}
onTouchStart={e => { touchStartY.current = e.touches[0].clientY; }}
onTouchEnd={e => {
if (touchStartY.current !== null && notice.dismissible) {
const delta = e.changedTouches[0].clientY - touchStartY.current;
if (delta > 80) handleDismiss();
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden h-[85dvh] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
style={{ touchAction: 'pan-y' }}
onTouchStart={e => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
dragLockRef.current = null;
scrollTopAtTouchStart.current = contentWrapperRef.current?.scrollTop ?? 0;
}}
onTouchMove={e => {
if (prefersReducedMotion) return;
const startX = touchStartX.current;
const startY = touchStartY.current;
if (startX === null || startY === null) return;
const dx = e.touches[0].clientX - startX;
const dy = e.touches[0].clientY - startY;
// Classify gesture direction on first significant movement
if (!dragLockRef.current) {
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
// Reset adjacent slots to top before they slide into view.
if (dragLockRef.current === 'h') {
prevSlotRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
}
}
return;
}
if (dragLockRef.current === 'h') {
const strip = stripRef.current;
if (!strip) return;
strip.style.transition = 'none';
// Strip base = -33.333% (center slot visible); dx offsets from there
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
} else if (dragLockRef.current === 'v' && notice.dismissible) {
// Only intercept downward drag for dismiss when the sheet is scrolled to the top.
// If scrolled into content, let native pan-y scroll it back up.
if (scrollTopAtTouchStart.current > 0) return;
const sheet = sheetRef.current;
if (!sheet || dy <= 0) return;
sheet.style.transition = 'none';
sheet.style.transform = `translateY(${dy}px)`;
}
}}
onTouchEnd={e => {
const startX = touchStartX.current;
const startY = touchStartY.current;
touchStartX.current = null;
touchStartY.current = null;
const lock = dragLockRef.current;
dragLockRef.current = null;
if (lock === 'h') {
if (startX === null) return;
const deltaX = e.changedTouches[0].clientX - startX;
const strip = stripRef.current;
if (!strip) return;
const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50;
const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50;
const canGoNext = canPage && idx < notices.length - 1;
const canGoPrev = canPage && idx > 0;
if ((goNext && canGoNext) || (goPrev && canGoPrev)) {
// Animate strip to the adjacent slot (-66.666% = next, 0% = prev)
strip.style.transition = 'transform 200ms ease-out';
strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)';
strip.addEventListener('transitionend', function onDone() {
strip.removeEventListener('transitionend', onDone);
strip.style.transition = 'none';
// Render new content into the center slot BEFORE moving the strip,
// so the browser never paints old content at the center position.
const newIdx = goNext ? idx + 1 : idx - 1;
flushSync(() => {
isPageNavRef.current = true;
setIdx(newIdx);
announceIndex(newIdx, notices.length);
});
// Reset all slot scrolls so the new center starts at top.
prevSlotRef.current?.scrollTo({ top: 0 });
contentWrapperRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
} else {
// Spring back to center
strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
strip.style.transform = 'translateX(-33.333%)';
strip.addEventListener('transitionend', function onSnap() {
strip.removeEventListener('transitionend', onSnap);
strip.style.transition = '';
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
}
return;
}
// Vertical drag — animated dismiss or spring back (only when at scroll top)
if (lock === 'v' && startY !== null && scrollTopAtTouchStart.current === 0) {
const deltaY = e.changedTouches[0].clientY - startY;
const sheet = sheetRef.current;
if (deltaY > 80 && notice.dismissible) {
animatedDismissAll();
} else if (sheet && deltaY > 0) {
sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
sheet.style.transform = 'translateY(0)';
sheet.addEventListener('transitionend', function onSnap() {
sheet.removeEventListener('transitionend', onSnap);
sheet.style.transition = '';
sheet.style.transform = '';
}, { once: true });
}
}
}}
>
{/* Drag handle */}
<div className="pt-3 pb-1 flex justify-center">
{/* Drag handle — fixed, does not scroll */}
<div className="pt-3 pb-1 flex justify-center shrink-0">
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
</div>
<div ref={contentWrapperRef}>
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
<div style={{ flex: '1 1 0', minHeight: 0, overflow: 'hidden', width: '100%' }}>
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
<div
ref={stripRef}
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
>
<div ref={prevSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{prevNotice && <NoticeContent {...buildSlotProps(prevNotice, idx - 1)} />}
</div>
<div ref={contentWrapperRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<NoticeContent {...contentProps} />
</div>
<div ref={nextSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{nextNotice && <NoticeContent {...buildSlotProps(nextNotice, idx + 1)} />}
</div>
</div>
</div>
</div>
</div>
);
+3 -3
View File
@@ -386,7 +386,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
)}
{selectedItem && !isAddingNew && isMobile && (
<div onClick={e => { if (e.target === e.currentTarget) setSelectedId(null) }}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
<DetailPane
@@ -402,7 +402,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
{isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal(
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
className="modal-backdrop"
style={{ position: 'fixed', inset: 0, zIndex: 300, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}>
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}>
<div style={{ width: 'min(520px, 92vw)', maxHeight: 'calc(100vh - var(--nav-h) - 120px)', overflow: 'auto', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.25)' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px' } } }}>
<NewTaskPane
@@ -420,7 +420,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
{isAddingNew && !selectedItem && isMobile && ReactDOM.createPortal(
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
className="modal-backdrop"
style={{ position: 'fixed', inset: 0, zIndex: 300, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
<NewTaskPane
+1 -1
View File
@@ -51,7 +51,7 @@ export default function Modal({
return (
<div
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }}
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'
import { getCategoryIcon } from './categoryIcons'
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
import { useAuthStore } from '../../store/authStore'
import type { Place } from '../../types'
interface Category {
@@ -18,10 +19,12 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
const [visible, setVisible] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
// Observe visibility — fetch photo only when avatar enters viewport
useEffect(() => {
if (place.image_url) { setVisible(true); return }
if (!placesPhotosEnabled) return
const el = ref.current
if (!el) return
// Check if already cached — show immediately without waiting for intersection
@@ -37,6 +40,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
useEffect(() => {
if (!visible) return
if (place.image_url) { setPhotoSrc(place.image_url); return }
if (!placesPhotosEnabled) return
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
+18
View File
@@ -588,6 +588,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'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': 'الدردشة',
@@ -1579,6 +1585,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
'memories.confirmShareButton': 'مشاركة الصور',
'journey.search.placeholder': 'البحث في الرحلات…',
'journey.search.noResults': 'لا توجد رحلات تطابق "{query}"',
'journey.status.archived': 'مؤرشف',
'journey.settings.endJourney': 'أرشفة الرحلة',
'journey.settings.reopenJourney': 'استعادة الرحلة',
'journey.settings.archived': 'تم أرشفة الرحلة',
'journey.settings.reopened': 'تمت إعادة فتح الرحلة',
'journey.settings.endDescription': 'يخفي شارة البث المباشر. يمكنك إعادة الفتح في أي وقت.',
'journey.settings.failedToDelete': 'فشل في الحذف',
'journey.entries.deleteTitle': 'حذف الإدخال',
'journey.photosUploaded': 'تم رفع {count} صورة',
@@ -1889,6 +1903,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'فشل إرسال Ntfy التجريبي',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'يُرسل Ntfy للمسؤول دائمًا عند تهيئة موضوع',
'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
'admin.notifications.tripReminders.title': 'تذكيرات الرحلات',
'admin.notifications.tripReminders.hint': 'إرسال تذكير قبل بدء الرحلة (يتطلب تعيين أيام التذكير على الرحلة).',
'admin.notifications.tripReminders.enabled': 'تم تفعيل تذكيرات الرحلات',
'admin.notifications.tripReminders.disabled': 'تم تعطيل تذكيرات الرحلات',
'admin.tabs.notifications': 'الإشعارات',
'notifications.versionAvailable.title': 'تحديث متاح',
'notifications.versionAvailable.text': 'TREK {version} متاح الآن.',
+18
View File
@@ -546,6 +546,12 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -1838,6 +1844,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Falha ao enviar Ntfy de teste',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'O Ntfy de admin sempre dispara quando um tópico está configurado',
'admin.notifications.adminNotificationsHint': 'Configure quais canais entregam notificações de admin (ex. alertas de versão). O webhook dispara automaticamente se uma URL de webhook de admin estiver definida.',
'admin.notifications.tripReminders.title': 'Lembretes de viagem',
'admin.notifications.tripReminders.hint': 'Envia uma notificação de lembrete antes do início de uma viagem (requer dias de lembrete definidos na viagem).',
'admin.notifications.tripReminders.enabled': 'Lembretes de viagem ativados',
'admin.notifications.tripReminders.disabled': 'Lembretes de viagem desativados',
'admin.tabs.notifications': 'Notificações',
'notifications.versionAvailable.title': 'Atualização disponível',
'notifications.versionAvailable.text': 'TREK {version} já está disponível.',
@@ -1885,6 +1895,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.saveRouteNotConfigured': 'A rota de salvamento não está configurada para este provedor',
'memories.testRouteNotConfigured': 'A rota de teste não está configurada para este provedor',
'memories.fillRequiredFields': 'Por favor preencha todos os campos obrigatórios',
'journey.search.placeholder': 'Buscar jornadas…',
'journey.search.noResults': 'Nenhuma jornada corresponde a "{query}"',
'journey.title': 'Jornada',
'journey.subtitle': 'Registre suas viagens em tempo real',
'journey.new': 'Nova jornada',
@@ -1906,6 +1918,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Ativa',
'journey.status.completed': 'Concluída',
'journey.status.upcoming': 'Próxima',
'journey.status.archived': 'Arquivado',
'journey.checkin.add': 'Fazer check-in',
'journey.checkin.namePlaceholder': 'Nome do local',
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
@@ -2059,6 +2072,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Nome',
'journey.settings.subtitle': 'Subtítulo',
'journey.settings.subtitlePlaceholder': 'ex. Tailândia, Vietnã e Camboja',
'journey.settings.endJourney': 'Arquivar Jornada',
'journey.settings.reopenJourney': 'Restaurar Jornada',
'journey.settings.archived': 'Jornada arquivada',
'journey.settings.reopened': 'Jornada reaberta',
'journey.settings.endDescription': 'Oculta o selo Ao Vivo. Você pode reabrir a qualquer momento.',
'journey.settings.delete': 'Excluir',
'journey.settings.deleteJourney': 'Excluir jornada',
'journey.settings.deleteMessage': 'Excluir "{title}"? Todas as entradas e fotos serão perdidas.',
+18
View File
@@ -546,6 +546,12 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -1843,6 +1849,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Testovací Ntfy selhalo',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy odesílá vždy, když je nakonfigurováno téma',
'admin.notifications.adminNotificationsHint': 'Nastavte, které kanály doručují admin oznámení (např. upozornění na verze). Webhook odesílá automaticky, pokud je nastavena URL admin webhooku.',
'admin.notifications.tripReminders.title': 'Připomínky výletů',
'admin.notifications.tripReminders.hint': 'Odešle upozornění před začátkem výletu (vyžaduje nastavené dny připomínky na výletu).',
'admin.notifications.tripReminders.enabled': 'Připomínky výletů aktivovány',
'admin.notifications.tripReminders.disabled': 'Připomínky výletů deaktivovány',
'admin.tabs.notifications': 'Oznámení',
'notifications.versionAvailable.title': 'Dostupná aktualizace',
'notifications.versionAvailable.text': 'TREK {version} je nyní k dispozici.',
@@ -1890,6 +1900,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.saveRouteNotConfigured': 'Trasa uložení není nakonfigurována pro tohoto poskytovatele',
'memories.testRouteNotConfigured': 'Testovací trasa není nakonfigurována pro tohoto poskytovatele',
'memories.fillRequiredFields': 'Prosím vyplňte všechna povinná pole',
'journey.search.placeholder': 'Hledat cesty…',
'journey.search.noResults': 'Žádné cesty neodpovídají „{query}"',
'journey.title': 'Cestovní deník',
'journey.subtitle': 'Zaznamenávejte své cesty průběžně',
'journey.new': 'Nový cestovní deník',
@@ -1911,6 +1923,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktivní',
'journey.status.completed': 'Dokončeno',
'journey.status.upcoming': 'Nadcházející',
'journey.status.archived': 'Archivováno',
'journey.checkin.add': 'Odbavit se',
'journey.checkin.namePlaceholder': 'Název místa',
'journey.checkin.notesPlaceholder': 'Poznámky (volitelné)',
@@ -2064,6 +2077,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Název',
'journey.settings.subtitle': 'Podtitul',
'journey.settings.subtitlePlaceholder': 'např. Thajsko, Vietnam a Kambodža',
'journey.settings.endJourney': 'Archivovat cestu',
'journey.settings.reopenJourney': 'Obnovit cestu',
'journey.settings.archived': 'Cesta archivována',
'journey.settings.reopened': 'Cesta znovu otevřena',
'journey.settings.endDescription': 'Skryje odznak Živě. Kdykoli jej lze znovu otevřít.',
'journey.settings.delete': 'Smazat',
'journey.settings.deleteJourney': 'Smazat cestovní deník',
'journey.settings.deleteMessage': 'Smazat „{title}"? Všechny záznamy a fotky budou ztraceny.',
+18
View File
@@ -551,6 +551,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
'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',
@@ -1846,6 +1852,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy fehlgeschlagen',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy sendet immer, wenn ein Thema konfiguriert ist',
'admin.notifications.adminNotificationsHint': 'Konfiguriere, welche Kanäle Admin-Benachrichtigungen liefern (z. B. Versions-Updates). Der Webhook sendet automatisch, wenn eine Admin-Webhook-URL gesetzt ist.',
'admin.notifications.tripReminders.title': 'Reiseerinnerungen',
'admin.notifications.tripReminders.hint': 'Sendet eine Erinnerungsbenachrichtigung vor Reisebeginn (erfordert gesetzte Erinnerungstage bei der Reise).',
'admin.notifications.tripReminders.enabled': 'Reiseerinnerungen aktiviert',
'admin.notifications.tripReminders.disabled': 'Reiseerinnerungen deaktiviert',
'admin.tabs.notifications': 'Benachrichtigungen',
'notifications.versionAvailable.title': 'Update verfügbar',
'notifications.versionAvailable.text': 'TREK {version} ist jetzt verfügbar.',
@@ -1887,6 +1897,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert',
// Journey Addon
'journey.search.placeholder': 'Reisen suchen…',
'journey.search.noResults': 'Keine Reisen passen zu „{query}"',
'journey.title': 'Journey',
'journey.subtitle': 'Dokumentiere deine Reisen unterwegs',
'journey.new': 'Neue Journey',
@@ -1908,6 +1920,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktiv',
'journey.status.completed': 'Abgeschlossen',
'journey.status.upcoming': 'Anstehend',
'journey.status.archived': 'Archiviert',
'journey.checkin.add': 'Einchecken',
'journey.checkin.namePlaceholder': 'Ortsname',
'journey.checkin.notesPlaceholder': 'Notizen (optional)',
@@ -2065,6 +2078,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Name',
'journey.settings.subtitle': 'Untertitel',
'journey.settings.subtitlePlaceholder': 'z.B. Thailand, Vietnam & Kambodscha',
'journey.settings.endJourney': 'Reise archivieren',
'journey.settings.reopenJourney': 'Reise wiederherstellen',
'journey.settings.archived': 'Reise archiviert',
'journey.settings.reopened': 'Reise erneut geöffnet',
'journey.settings.endDescription': 'Blendet das Live-Abzeichen aus. Sie können jederzeit wieder öffnen.',
'journey.settings.delete': 'Löschen',
'journey.settings.deleteJourney': 'Journey löschen',
'journey.settings.deleteMessage': '"{title}" löschen? Alle Einträge und Fotos gehen verloren.',
+18
View File
@@ -253,6 +253,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).',
'admin.notifications.tripReminders.title': 'Trip Reminders',
'admin.notifications.tripReminders.hint': 'Send a reminder notification before a trip starts (requires reminder days to be set on the trip).',
'admin.notifications.tripReminders.enabled': 'Trip reminders enabled',
'admin.notifications.tripReminders.disabled': 'Trip reminders disabled',
'admin.smtp.title': 'Email & Notifications',
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
'admin.smtp.testButton': 'Send test email',
@@ -607,6 +611,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
'admin.fileTypesSaved': 'File type settings saved',
'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',
@@ -1890,6 +1900,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
// Journey addon
'journey.search.placeholder': 'Search journeys…',
'journey.search.noResults': 'No journeys match "{query}"',
'journey.title': 'Journey',
'journey.subtitle': 'Track your travels as they happen',
'journey.new': 'New Journey',
@@ -1911,6 +1923,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Active',
'journey.status.completed': 'Completed',
'journey.status.upcoming': 'Upcoming',
'journey.status.archived': 'Archived',
'journey.checkin.add': 'Check in',
'journey.checkin.namePlaceholder': 'Location name',
'journey.checkin.notesPlaceholder': 'Notes (optional)',
@@ -2089,6 +2102,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Name',
'journey.settings.subtitle': 'Subtitle',
'journey.settings.subtitlePlaceholder': 'e.g. Thailand, Vietnam & Cambodia',
'journey.settings.endJourney': 'Archive Journey',
'journey.settings.reopenJourney': 'Restore Journey',
'journey.settings.archived': 'Journey archived',
'journey.settings.reopened': 'Journey reopened',
'journey.settings.endDescription': 'Hides the Live badge. You can reopen anytime.',
'journey.settings.delete': 'Delete',
'journey.settings.deleteJourney': 'Delete Journey',
'journey.settings.deleteMessage': 'Delete "{title}"? All entries and photos will be lost.',
+18
View File
@@ -541,6 +541,12 @@ const es: Record<string, string> = {
'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',
@@ -1848,6 +1854,10 @@ const es: Record<string, string> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Error al enviar el Ntfy de prueba',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'El Ntfy de admin siempre se activa cuando hay un tema configurado',
'admin.notifications.adminNotificationsHint': 'Configura qué canales entregan notificaciones de admin (ej. alertas de versión). El webhook se activa automáticamente si hay una URL de webhook de admin configurada.',
'admin.notifications.tripReminders.title': 'Recordatorios de viaje',
'admin.notifications.tripReminders.hint': 'Envía una notificación de recordatorio antes de que comience un viaje (requiere días de recordatorio configurados en el viaje).',
'admin.notifications.tripReminders.enabled': 'Recordatorios de viaje activados',
'admin.notifications.tripReminders.disabled': 'Recordatorios de viaje desactivados',
'admin.tabs.notifications': 'Notificaciones',
'notifications.versionAvailable.title': 'Actualización disponible',
'notifications.versionAvailable.text': 'TREK {version} ya está disponible.',
@@ -1892,6 +1902,8 @@ const es: Record<string, string> = {
'common.justNow': 'justo ahora',
'common.hoursAgo': 'hace {count}h',
'common.daysAgo': 'hace {count}d',
'journey.search.placeholder': 'Buscar viajes…',
'journey.search.noResults': 'Ningún viaje coincide con "{query}"',
'journey.title': 'Travesía',
'journey.subtitle': 'Registra tus viajes en tiempo real',
'journey.new': 'Nueva travesía',
@@ -1913,6 +1925,7 @@ const es: Record<string, string> = {
'journey.status.active': 'Activa',
'journey.status.completed': 'Completada',
'journey.status.upcoming': 'Próxima',
'journey.status.archived': 'Archivado',
'journey.checkin.add': 'Registrar ubicación',
'journey.checkin.namePlaceholder': 'Nombre del lugar',
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
@@ -2066,6 +2079,11 @@ const es: Record<string, string> = {
'journey.settings.name': 'Nombre',
'journey.settings.subtitle': 'Subtítulo',
'journey.settings.subtitlePlaceholder': 'p. ej. Tailandia, Vietnam y Camboya',
'journey.settings.endJourney': 'Archivar viaje',
'journey.settings.reopenJourney': 'Restaurar viaje',
'journey.settings.archived': 'Viaje archivado',
'journey.settings.reopened': 'Viaje reabierto',
'journey.settings.endDescription': 'Oculta la insignia En Vivo. Puedes reabrirlo en cualquier momento.',
'journey.settings.delete': 'Eliminar',
'journey.settings.deleteJourney': 'Eliminar travesía',
'journey.settings.deleteMessage': '¿Eliminar "{title}"? Todas las entradas y fotos se perderán.',
+18
View File
@@ -545,6 +545,12 @@ const fr: Record<string, string> = {
'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',
@@ -1842,6 +1848,10 @@ const fr: Record<string, string> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Échec de l\'envoi du Ntfy de test',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Le Ntfy admin s\'active toujours lorsqu\'un sujet est configuré',
'admin.notifications.adminNotificationsHint': 'Configurez quels canaux envoient les notifications admin (ex. alertes de version). Le webhook s\'active automatiquement si une URL webhook admin est définie.',
'admin.notifications.tripReminders.title': 'Rappels de voyage',
'admin.notifications.tripReminders.hint': 'Envoie une notification de rappel avant le début d\'un voyage (nécessite des jours de rappel définis sur le voyage).',
'admin.notifications.tripReminders.enabled': 'Rappels de voyage activés',
'admin.notifications.tripReminders.disabled': 'Rappels de voyage désactivés',
'admin.tabs.notifications': 'Notifications',
'notifications.versionAvailable.title': 'Mise à jour disponible',
'notifications.versionAvailable.text': 'TREK {version} est maintenant disponible.',
@@ -1886,6 +1896,8 @@ const fr: Record<string, string> = {
'common.justNow': 'à l\'instant',
'common.hoursAgo': 'il y a {count}h',
'common.daysAgo': 'il y a {count}j',
'journey.search.placeholder': 'Rechercher des journaux…',
'journey.search.noResults': 'Aucun journal ne correspond à « {query} »',
'journey.title': 'Journal de voyage',
'journey.subtitle': 'Suivez vos voyages en temps réel',
'journey.new': 'Nouveau journal',
@@ -1907,6 +1919,7 @@ const fr: Record<string, string> = {
'journey.status.active': 'Actif',
'journey.status.completed': 'Terminé',
'journey.status.upcoming': 'À venir',
'journey.status.archived': 'Archivé',
'journey.checkin.add': 'Check-in',
'journey.checkin.namePlaceholder': 'Nom du lieu',
'journey.checkin.notesPlaceholder': 'Notes (facultatif)',
@@ -2060,6 +2073,11 @@ const fr: Record<string, string> = {
'journey.settings.name': 'Nom',
'journey.settings.subtitle': 'Sous-titre',
'journey.settings.subtitlePlaceholder': 'ex. Thaïlande, Vietnam et Cambodge',
'journey.settings.endJourney': 'Archiver le journal',
'journey.settings.reopenJourney': 'Restaurer le journal',
'journey.settings.archived': 'Journal archivé',
'journey.settings.reopened': 'Journal rouvert',
'journey.settings.endDescription': 'Masque l\'indicateur En direct. Vous pouvez rouvrir à tout moment.',
'journey.settings.delete': 'Supprimer',
'journey.settings.deleteJourney': 'Supprimer le journal',
'journey.settings.deleteMessage': 'Supprimer « {title} » ? Toutes les entrées et photos seront perdues.',
+18
View File
@@ -546,6 +546,12 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -1840,6 +1846,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Teszt Ntfy sikertelen',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Az admin Ntfy mindig küld, ha egy téma konfigurálva van',
'admin.notifications.adminNotificationsHint': 'Állítsa be, hogy mely csatornák szállítsák az admin értesítéseket (pl. verziófrissítési figyelmeztetések). A webhook automatikusan küld, ha admin webhook URL van megadva.',
'admin.notifications.tripReminders.title': 'Utazási emlékeztetők',
'admin.notifications.tripReminders.hint': 'Emlékeztető értesítést küld az utazás kezdete előtt (az utazásnál megadott emlékeztető napok szükségesek).',
'admin.notifications.tripReminders.enabled': 'Utazási emlékeztetők engedélyezve',
'admin.notifications.tripReminders.disabled': 'Utazási emlékeztetők letiltva',
'admin.tabs.notifications': 'Értesítések',
'notifications.versionAvailable.title': 'Elérhető frissítés',
'notifications.versionAvailable.text': 'A TREK {version} már elérhető.',
@@ -1887,6 +1897,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'memories.saveRouteNotConfigured': 'A mentési útvonal nincs konfigurálva ehhez a szolgáltatóhoz',
'memories.testRouteNotConfigured': 'A tesztútvonal nincs konfigurálva ehhez a szolgáltatóhoz',
'memories.fillRequiredFields': 'Kérjük töltse ki az összes kötelező mezőt',
'journey.search.placeholder': 'Utak keresése…',
'journey.search.noResults': 'Nincs „{query}" kifejezéssel egyező út',
'journey.title': 'Útinaplók',
'journey.subtitle': 'Kövesse nyomon utazásait valós időben',
'journey.new': 'Új útinapló',
@@ -1908,6 +1920,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktív',
'journey.status.completed': 'Befejezett',
'journey.status.upcoming': 'Közelgő',
'journey.status.archived': 'Archivált',
'journey.checkin.add': 'Bejelentkezés',
'journey.checkin.namePlaceholder': 'Helyszín neve',
'journey.checkin.notesPlaceholder': 'Jegyzetek (opcionális)',
@@ -2061,6 +2074,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Név',
'journey.settings.subtitle': 'Alcím',
'journey.settings.subtitlePlaceholder': 'pl. Thaiföld, Vietnam és Kambodzsa',
'journey.settings.endJourney': 'Út archiválása',
'journey.settings.reopenJourney': 'Út visszaállítása',
'journey.settings.archived': 'Út archiválva',
'journey.settings.reopened': 'Út újranyitva',
'journey.settings.endDescription': 'Elrejti az Élő jelzést. Bármikor újranyitható.',
'journey.settings.delete': 'Törlés',
'journey.settings.deleteJourney': 'Útinapló törlése',
'journey.settings.deleteMessage': 'Törlöd a(z) „{title}" útinaplót? Minden bejegyzés és fotó elveszik.',
+18
View File
@@ -251,6 +251,10 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Uji Ntfy gagal',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy selalu berjalan jika topik dikonfigurasi',
'admin.notifications.adminNotificationsHint': 'Atur saluran mana yang mengirimkan notifikasi khusus admin (mis. peringatan versi).',
'admin.notifications.tripReminders.title': 'Pengingat Perjalanan',
'admin.notifications.tripReminders.hint': 'Mengirim notifikasi pengingat sebelum perjalanan dimulai (memerlukan hari pengingat yang diatur pada perjalanan).',
'admin.notifications.tripReminders.enabled': 'Pengingat perjalanan diaktifkan',
'admin.notifications.tripReminders.disabled': 'Pengingat perjalanan dinonaktifkan',
'admin.smtp.title': 'Email & Notifikasi',
'admin.smtp.hint': 'Konfigurasi SMTP untuk pengiriman notifikasi email.',
'admin.smtp.testButton': 'Kirim email uji',
@@ -606,6 +610,12 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -1890,6 +1900,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'notif.dev.unknown_event.text': 'Tipe event "{event}" tidak terdaftar di EVENT_NOTIFICATION_CONFIG',
// Journey addon
'journey.search.placeholder': 'Cari perjalanan…',
'journey.search.noResults': 'Tidak ada perjalanan yang cocok dengan "{query}"',
'journey.title': 'Journey',
'journey.subtitle': 'Lacak perjalananmu saat terjadi',
'journey.new': 'Journey Baru',
@@ -1911,6 +1923,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktif',
'journey.status.completed': 'Selesai',
'journey.status.upcoming': 'Mendatang',
'journey.status.archived': 'Diarsipkan',
'journey.checkin.add': 'Check in',
'journey.checkin.namePlaceholder': 'Nama lokasi',
'journey.checkin.notesPlaceholder': 'Catatan (opsional)',
@@ -2088,6 +2101,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Nama',
'journey.settings.subtitle': 'Subjudul',
'journey.settings.subtitlePlaceholder': 'mis. Thailand, Vietnam & Kamboja',
'journey.settings.endJourney': 'Arsipkan Perjalanan',
'journey.settings.reopenJourney': 'Pulihkan Perjalanan',
'journey.settings.archived': 'Perjalanan diarsipkan',
'journey.settings.reopened': 'Perjalanan dibuka kembali',
'journey.settings.endDescription': 'Menyembunyikan lencana Langsung. Anda dapat membuka kembali kapan saja.',
'journey.settings.delete': 'Hapus',
'journey.settings.deleteJourney': 'Hapus Journey',
'journey.settings.deleteMessage': 'Hapus "{title}"? Semua entri dan foto akan hilang.',
+18
View File
@@ -545,6 +545,12 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -1843,6 +1849,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Invio Ntfy di test fallito',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Il Ntfy admin si attiva sempre quando un argomento è configurato',
'admin.notifications.adminNotificationsHint': 'Configura quali canali consegnano le notifiche admin (es. avvisi di versione). Il webhook si attiva automaticamente se è impostato un URL webhook admin.',
'admin.notifications.tripReminders.title': 'Promemoria viaggio',
'admin.notifications.tripReminders.hint': 'Invia una notifica promemoria prima dell\'inizio di un viaggio (richiede giorni di promemoria impostati sul viaggio).',
'admin.notifications.tripReminders.enabled': 'Promemoria viaggio attivati',
'admin.notifications.tripReminders.disabled': 'Promemoria viaggio disattivati',
'admin.tabs.notifications': 'Notifiche',
'notifications.versionAvailable.title': 'Aggiornamento disponibile',
'notifications.versionAvailable.text': 'TREK {version} è ora disponibile.',
@@ -1887,6 +1897,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.justNow': 'proprio ora',
'common.hoursAgo': '{count}h fa',
'common.daysAgo': '{count}g fa',
'journey.search.placeholder': 'Cerca viaggi…',
'journey.search.noResults': 'Nessun viaggio corrisponde a "{query}"',
'journey.title': 'Diario di viaggio',
'journey.subtitle': 'Segui i tuoi viaggi in tempo reale',
'journey.new': 'Nuovo diario',
@@ -1908,6 +1920,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Attivo',
'journey.status.completed': 'Completato',
'journey.status.upcoming': 'In arrivo',
'journey.status.archived': 'Archiviato',
'journey.checkin.add': 'Check-in',
'journey.checkin.namePlaceholder': 'Nome del luogo',
'journey.checkin.notesPlaceholder': 'Note (facoltativo)',
@@ -2061,6 +2074,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Nome',
'journey.settings.subtitle': 'Sottotitolo',
'journey.settings.subtitlePlaceholder': 'es. Thailandia, Vietnam e Cambogia',
'journey.settings.endJourney': 'Archivia il viaggio',
'journey.settings.reopenJourney': 'Ripristina il viaggio',
'journey.settings.archived': 'Viaggio archiviato',
'journey.settings.reopened': 'Viaggio riaperto',
'journey.settings.endDescription': 'Nasconde il badge In diretta. Puoi riaprire in qualsiasi momento.',
'journey.settings.delete': 'Elimina',
'journey.settings.deleteJourney': 'Elimina diario',
'journey.settings.deleteMessage': 'Eliminare "{title}"? Tutte le voci e le foto andranno perse.',
+18
View File
@@ -546,6 +546,12 @@ const nl: Record<string, string> = {
'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',
@@ -1842,6 +1848,10 @@ const nl: Record<string, string> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy mislukt',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy verstuurt altijd wanneer een onderwerp is geconfigureerd',
'admin.notifications.adminNotificationsHint': 'Stel in via welke kanalen admin-meldingen worden bezorgd (bijv. versie-updates). De webhook verstuurt automatisch als er een admin-webhook-URL is ingesteld.',
'admin.notifications.tripReminders.title': 'Reisherinneringen',
'admin.notifications.tripReminders.hint': 'Stuurt een herinneringsmelding voor de start van een reis (vereist ingestelde herinneringsdagen bij de reis).',
'admin.notifications.tripReminders.enabled': 'Reisherinneringen ingeschakeld',
'admin.notifications.tripReminders.disabled': 'Reisherinneringen uitgeschakeld',
'admin.tabs.notifications': 'Meldingen',
'notifications.versionAvailable.title': 'Update beschikbaar',
'notifications.versionAvailable.text': 'TREK {version} is nu beschikbaar.',
@@ -1886,6 +1896,8 @@ const nl: Record<string, string> = {
'common.justNow': 'zojuist',
'common.hoursAgo': '{count}u geleden',
'common.daysAgo': '{count}d geleden',
'journey.search.placeholder': 'Reizen zoeken…',
'journey.search.noResults': 'Geen reizen komen overeen met "{query}"',
'journey.title': 'Reisverslag',
'journey.subtitle': 'Leg je reizen vast terwijl je onderweg bent',
'journey.new': 'Nieuw reisverslag',
@@ -1907,6 +1919,7 @@ const nl: Record<string, string> = {
'journey.status.active': 'Actief',
'journey.status.completed': 'Voltooid',
'journey.status.upcoming': 'Gepland',
'journey.status.archived': 'Gearchiveerd',
'journey.checkin.add': 'Inchecken',
'journey.checkin.namePlaceholder': 'Locatienaam',
'journey.checkin.notesPlaceholder': 'Notities (optioneel)',
@@ -2060,6 +2073,11 @@ const nl: Record<string, string> = {
'journey.settings.name': 'Naam',
'journey.settings.subtitle': 'Ondertitel',
'journey.settings.subtitlePlaceholder': 'bijv. Thailand, Vietnam & Cambodja',
'journey.settings.endJourney': 'Reis archiveren',
'journey.settings.reopenJourney': 'Reis herstellen',
'journey.settings.archived': 'Reis gearchiveerd',
'journey.settings.reopened': 'Reis heropend',
'journey.settings.endDescription': 'Verbergt het Live-badge. Je kunt het altijd heropenen.',
'journey.settings.delete': 'Verwijderen',
'journey.settings.deleteJourney': 'Reisverslag verwijderen',
'journey.settings.deleteMessage': '"{title}" verwijderen? Alle vermeldingen en foto\'s gaan verloren.',
+18
View File
@@ -518,6 +518,12 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -1642,6 +1648,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Wysyłanie testowego Ntfy nie powiodło się',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy zawsze wysyła po skonfigurowaniu tematu',
'admin.notifications.adminNotificationsHint': 'Skonfiguruj, które kanały dostarczają powiadomienia admina (np. alerty o wersjach). Webhook wysyła automatycznie, gdy ustawiony jest URL webhooka admina.',
'admin.notifications.tripReminders.title': 'Przypomnienia o podróżach',
'admin.notifications.tripReminders.hint': 'Wysyła powiadomienie z przypomnieniem przed rozpoczęciem podróży (wymaga ustawienia dni przypomnienia dla podróży).',
'admin.notifications.tripReminders.enabled': 'Przypomnienia o podróżach włączone',
'admin.notifications.tripReminders.disabled': 'Przypomnienia o podróżach wyłączone',
'admin.webhook.hint': 'Pozwól użytkownikom konfigurować własne adresy URL webhooka dla powiadomień (Discord, Slack itp.).',
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
'settings.notificationPreferences.noChannels': 'Brak skonfigurowanych kanałów powiadomień. Poproś administratora o skonfigurowanie powiadomień e-mail lub webhook.',
@@ -1879,6 +1889,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'memories.saveRouteNotConfigured': 'Trasa zapisu nie jest skonfigurowana dla tego dostawcy',
'memories.testRouteNotConfigured': 'Trasa testowa nie jest skonfigurowana dla tego dostawcy',
'memories.fillRequiredFields': 'Proszę wypełnić wszystkie wymagane pola',
'journey.search.placeholder': 'Szukaj podróży…',
'journey.search.noResults': 'Brak podróży pasujących do „{query}"',
'journey.title': 'Dziennik podróży',
'journey.subtitle': 'Dokumentuj swoje podróże na bieżąco',
'journey.new': 'Nowy dziennik podróży',
@@ -1900,6 +1912,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktywny',
'journey.status.completed': 'Zakończony',
'journey.status.upcoming': 'Nadchodzący',
'journey.status.archived': 'Zarchiwizowano',
'journey.checkin.add': 'Zamelduj się',
'journey.checkin.namePlaceholder': 'Nazwa miejsca',
'journey.checkin.notesPlaceholder': 'Notatki (opcjonalnie)',
@@ -2053,6 +2066,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Nazwa',
'journey.settings.subtitle': 'Podtytuł',
'journey.settings.subtitlePlaceholder': 'np. Tajlandia, Wietnam i Kambodża',
'journey.settings.endJourney': 'Archiwizuj podróż',
'journey.settings.reopenJourney': 'Przywróć podróż',
'journey.settings.archived': 'Podróż zarchiwizowana',
'journey.settings.reopened': 'Podróż wznowiona',
'journey.settings.endDescription': 'Ukrywa odznakę Na żywo. Możesz wznowić w dowolnym momencie.',
'journey.settings.delete': 'Usuń',
'journey.settings.deleteJourney': 'Usuń dziennik podróży',
'journey.settings.deleteMessage': 'Usunąć „{title}"? Wszystkie wpisy i zdjęcia zostaną utracone.',
+18
View File
@@ -546,6 +546,12 @@ const ru: Record<string, string> = {
'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': 'Чат',
@@ -1839,6 +1845,10 @@ const ru: Record<string, string> = {
'admin.notifications.adminNtfyPanel.testFailed': 'Ошибка отправки тестового Ntfy',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Ntfy администратора всегда отправляется при наличии настроенной темы',
'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
'admin.notifications.tripReminders.title': 'Напоминания о поездках',
'admin.notifications.tripReminders.hint': 'Отправляет напоминание перед началом поездки (необходимо указать дни напоминания в параметрах поездки).',
'admin.notifications.tripReminders.enabled': 'Напоминания о поездках включены',
'admin.notifications.tripReminders.disabled': 'Напоминания о поездках отключены',
'admin.tabs.notifications': 'Уведомления',
'notifications.versionAvailable.title': 'Доступно обновление',
'notifications.versionAvailable.text': 'TREK {version} теперь доступен.',
@@ -1886,6 +1896,8 @@ const ru: Record<string, string> = {
'memories.saveRouteNotConfigured': 'Маршрут сохранения не настроен для этого провайдера',
'memories.testRouteNotConfigured': 'Маршрут тестирования не настроен для этого провайдера',
'memories.fillRequiredFields': 'Пожалуйста, заполните все обязательные поля',
'journey.search.placeholder': 'Поиск путешествий…',
'journey.search.noResults': 'Путешествий по запросу «{query}» не найдено',
'journey.title': 'Путешествие',
'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени',
'journey.new': 'Новое путешествие',
@@ -1907,6 +1919,7 @@ const ru: Record<string, string> = {
'journey.status.active': 'Активно',
'journey.status.completed': 'Завершено',
'journey.status.upcoming': 'Предстоящее',
'journey.status.archived': 'В архиве',
'journey.checkin.add': 'Отметиться',
'journey.checkin.namePlaceholder': 'Название места',
'journey.checkin.notesPlaceholder': 'Заметки (необязательно)',
@@ -2060,6 +2073,11 @@ const ru: Record<string, string> = {
'journey.settings.name': 'Название',
'journey.settings.subtitle': 'Подзаголовок',
'journey.settings.subtitlePlaceholder': 'напр. Таиланд, Вьетнам и Камбоджа',
'journey.settings.endJourney': 'Архивировать путешествие',
'journey.settings.reopenJourney': 'Восстановить путешествие',
'journey.settings.archived': 'Путешествие архивировано',
'journey.settings.reopened': 'Путешествие возобновлено',
'journey.settings.endDescription': 'Скрывает значок «В эфире». Вы можете возобновить в любое время.',
'journey.settings.delete': 'Удалить',
'journey.settings.deleteJourney': 'Удалить путешествие',
'journey.settings.deleteMessage': 'Удалить «{title}»? Все записи и фото будут потеряны.',
+18
View File
@@ -546,6 +546,12 @@ const zh: Record<string, string> = {
'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': '聊天',
@@ -1839,6 +1845,10 @@ const zh: Record<string, string> = {
'admin.notifications.adminNtfyPanel.testFailed': '测试 Ntfy 失败',
'admin.notifications.adminNtfyPanel.alwaysOnHint': '配置主题后管理员 Ntfy 始终触发',
'admin.notifications.adminNotificationsHint': '配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后,Webhook 将自动触发。',
'admin.notifications.tripReminders.title': '行程提醒',
'admin.notifications.tripReminders.hint': '在行程开始前发送提醒通知(需要在行程中设置提醒天数)。',
'admin.notifications.tripReminders.enabled': '行程提醒已启用',
'admin.notifications.tripReminders.disabled': '行程提醒已禁用',
'admin.tabs.notifications': '通知',
'notifications.versionAvailable.title': '有可用更新',
'notifications.versionAvailable.text': 'TREK {version} 现已可用。',
@@ -1886,6 +1896,8 @@ const zh: Record<string, string> = {
'memories.saveRouteNotConfigured': '此提供商未配置保存路由',
'memories.testRouteNotConfigured': '此提供商未配置测试路由',
'memories.fillRequiredFields': '请填写所有必填字段',
'journey.search.placeholder': '搜索旅程…',
'journey.search.noResults': '没有与"{query}"匹配的旅程',
'journey.title': '旅程',
'journey.subtitle': '实时记录你的旅行',
'journey.new': '新建旅程',
@@ -1907,6 +1919,7 @@ const zh: Record<string, string> = {
'journey.status.active': '进行中',
'journey.status.completed': '已完成',
'journey.status.upcoming': '即将开始',
'journey.status.archived': '已归档',
'journey.checkin.add': '签到',
'journey.checkin.namePlaceholder': '地点名称',
'journey.checkin.notesPlaceholder': '备注(可选)',
@@ -2060,6 +2073,11 @@ const zh: Record<string, string> = {
'journey.settings.name': '名称',
'journey.settings.subtitle': '副标题',
'journey.settings.subtitlePlaceholder': '例如 泰国、越南和柬埔寨',
'journey.settings.endJourney': '归档旅程',
'journey.settings.reopenJourney': '恢复旅程',
'journey.settings.archived': '旅程已归档',
'journey.settings.reopened': '旅程已重新开启',
'journey.settings.endDescription': '隐藏直播标记。您可以随时重新开启。',
'journey.settings.delete': '删除',
'journey.settings.deleteJourney': '删除旅程',
'journey.settings.deleteMessage': '删除"{title}"?所有条目和照片将丢失。',
+18
View File
@@ -251,6 +251,10 @@ const zhTw: Record<string, string> = {
'admin.notifications.adminNtfyPanel.testFailed': '測試 Ntfy 失敗',
'admin.notifications.adminNtfyPanel.alwaysOnHint': '設定主題後管理員 Ntfy 始終觸發',
'admin.notifications.adminNotificationsHint': '配置哪些渠道傳遞僅管理員通知(例如版本提醒)。',
'admin.notifications.tripReminders.title': '行程提醒',
'admin.notifications.tripReminders.hint': '在行程開始前發送提醒通知(需要在行程中設定提醒天數)。',
'admin.notifications.tripReminders.enabled': '行程提醒已啟用',
'admin.notifications.tripReminders.disabled': '行程提醒已停用',
'admin.smtp.title': '郵件與通知',
'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。',
'admin.smtp.testButton': '傳送測試郵件',
@@ -602,6 +606,12 @@ const zhTw: Record<string, string> = {
'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': '聊天',
@@ -1846,6 +1856,8 @@ const zhTw: Record<string, string> = {
'memories.saveRouteNotConfigured': '此提供商未設定儲存路由',
'memories.testRouteNotConfigured': '此提供商未設定測試路由',
'memories.fillRequiredFields': '請填寫所有必填欄位',
'journey.search.placeholder': '搜尋旅程…',
'journey.search.noResults': '沒有符合「{query}」的旅程',
'journey.title': '旅程',
'journey.subtitle': '即時記錄你的旅行',
'journey.new': '新建旅程',
@@ -1867,6 +1879,7 @@ const zhTw: Record<string, string> = {
'journey.status.active': '進行中',
'journey.status.completed': '已完成',
'journey.status.upcoming': '即將開始',
'journey.status.archived': '已封存',
'journey.checkin.add': '打卡',
'journey.checkin.namePlaceholder': '地點名稱',
'journey.checkin.notesPlaceholder': '備註(可選)',
@@ -2020,6 +2033,11 @@ const zhTw: Record<string, string> = {
'journey.settings.name': '名稱',
'journey.settings.subtitle': '副標題',
'journey.settings.subtitlePlaceholder': '例如 泰國、越南和柬埔寨',
'journey.settings.endJourney': '封存旅程',
'journey.settings.reopenJourney': '還原旅程',
'journey.settings.archived': '旅程已封存',
'journey.settings.reopened': '旅程已重新開啟',
'journey.settings.endDescription': '隱藏直播標記。您可以隨時重新開啟。',
'journey.settings.delete': '刪除',
'journey.settings.deleteJourney': '刪除旅程',
'journey.settings.deleteMessage': '刪除「{title}」?所有條目和照片將遺失。',
+105 -1
View File
@@ -194,6 +194,18 @@ export default function AdminPage(): React.ReactElement {
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
// Places photos
const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, [])
// Places autocomplete
const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, [])
// Places details
const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState<boolean>(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(() => {}) }, [])
@@ -242,7 +254,7 @@ export default function AdminPage(): React.ReactElement {
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, logout } = useAuthStore()
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore()
const navigate = useNavigate()
const toast = useToast()
@@ -1023,6 +1035,66 @@ export default function AdminPage(): React.ReactElement {
)}
</div>
{/* Place Photos Toggle */}
<div className="flex items-center justify-between py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.placesPhotos.title')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesPhotos.subtitle')}</p>
</div>
<button
onClick={async () => {
const next = !placesPhotosEnabled
setPlacesPhotosEnabledState(next)
setPlacesPhotosEnabled(next)
try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) }
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: placesPhotosEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesPhotosEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
{/* Place Autocomplete Toggle */}
<div className="flex items-center justify-between py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.placesAutocomplete.title')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesAutocomplete.subtitle')}</p>
</div>
<button
onClick={async () => {
const next = !placesAutocompleteEnabled
setPlacesAutocompleteEnabledState(next)
setPlacesAutocompleteEnabled(next)
try { await adminApi.updatePlacesAutocomplete(next) } catch { setPlacesAutocompleteEnabledState(!next); setPlacesAutocompleteEnabled(!next) }
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: placesAutocompleteEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesAutocompleteEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
{/* Place Details Toggle */}
<div className="flex items-center justify-between py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.placesDetails.title')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesDetails.subtitle')}</p>
</div>
<button
onClick={async () => {
const next = !placesDetailsEnabled
setPlacesDetailsEnabledState(next)
setPlacesDetailsEnabled(next)
try { await adminApi.updatePlacesDetails(next) } catch { setPlacesDetailsEnabledState(!next); setPlacesDetailsEnabled(!next) }
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: placesDetailsEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesDetailsEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
{/* Open-Meteo Weather Info */}
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
<div className="px-4 py-3 flex items-center justify-between">
@@ -1180,6 +1252,7 @@ export default function AdminPage(): React.ReactElement {
const emailActive = activeChans.includes('email')
const webhookActive = activeChans.includes('webhook')
const ntfyActive = activeChans.includes('ntfy')
const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none'
@@ -1338,6 +1411,37 @@ export default function AdminPage(): React.ReactElement {
</div>
</div>
{/* Trip Reminders Toggle */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.notifications.tripReminders.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.tripReminders.hint')}</p>
</div>
<button
onClick={async () => {
const next = !tripRemindersActive
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: next ? 'true' : 'false' }))
try {
await authApi.updateAppSettings({ notify_trip_reminder: next ? 'true' : 'false' })
toast.success(next ? t('admin.notifications.tripReminders.enabled') : t('admin.notifications.tripReminders.disabled'))
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {})
} catch {
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: tripRemindersActive ? 'true' : 'false' }))
toast.error(t('common.error'))
}
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
style={{ background: tripRemindersActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: tripRemindersActive ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
{/* Admin Webhook Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
+47 -33
View File
@@ -176,7 +176,7 @@ const mockJourneyDetail = {
avatar: null,
},
],
stats: { entries: 2, photos: 1, cities: 2 },
stats: { entries: 2, photos: 1, places: 2 },
};
// ── MSW Handlers ─────────────────────────────────────────────────────────────
@@ -265,8 +265,8 @@ describe('JourneyDetailPage', () => {
await renderAndWait();
const timelineBtn = screen.getByRole('button', { name: /timeline/i });
expect(timelineBtn).toBeInTheDocument();
// Timeline entries are visible by default
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
// Timeline entries are visible by default (gallery also mounted but hidden, so multiple matches are expected)
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
});
});
@@ -274,8 +274,8 @@ describe('JourneyDetailPage', () => {
describe('FE-PAGE-JOURNEYDETAIL-004: Shows entry cards with titles', () => {
it('renders all entry titles in timeline view', async () => {
await renderAndWait();
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
expect(screen.getByText('Florence Day')).toBeInTheDocument();
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
});
});
@@ -362,12 +362,12 @@ describe('JourneyDetailPage', () => {
expect(screen.getAllByText('Days').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Entries').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Photos').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Cities').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Places').length).toBeGreaterThanOrEqual(1);
});
it('renders stat values', async () => {
await renderAndWait();
// stats.entries = 2, stats.photos = 1, stats.cities = 2
// stats.entries = 2, stats.photos = 1, stats.places = 2
// Entries count appears in hero and sidebar
const twos = screen.getAllByText('2');
expect(twos.length).toBeGreaterThanOrEqual(1);
@@ -474,7 +474,7 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-018 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-018: Empty state when no entries', () => {
it('shows "No entries yet" when journey has no entries', async () => {
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } });
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } });
render(<JourneyDetailPage />);
@@ -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(<JourneyDetailPage />);
@@ -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(<JourneyDetailPage />);
@@ -610,12 +610,12 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [...mockJourneyDetail.entries, skeletonEntry],
stats: { entries: 3, photos: 1, cities: 3 },
stats: { entries: 3, photos: 1, places: 3 },
});
render(<JourneyDetailPage />);
await waitFor(() => {
expect(screen.getByText('Venice Visit')).toBeInTheDocument();
expect(screen.getAllByText('Venice Visit').length).toBeGreaterThanOrEqual(1);
});
// Skeleton card shows "Add Entry" CTA
@@ -650,15 +650,15 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [...mockJourneyDetail.entries, checkinEntry],
stats: { entries: 3, photos: 1, cities: 2 },
stats: { entries: 3, photos: 1, places: 2 },
});
render(<JourneyDetailPage />);
await waitFor(() => {
expect(screen.getByText('Quick stop at cafe')).toBeInTheDocument();
expect(screen.getAllByText('Quick stop at cafe').length).toBeGreaterThanOrEqual(1);
});
expect(screen.getByText(/Cafe Roma/)).toBeInTheDocument();
expect(screen.getAllByText(/Cafe Roma/).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Grabbed an espresso')).toBeInTheDocument();
});
});
@@ -707,15 +707,26 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-030 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-030: Active status badge shows Live indicator', () => {
it('renders a "Live" badge for active journeys', async () => {
it('renders a "Live" badge when linked trip spans today', async () => {
setupDefaultHandlers({
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
});
await renderAndWait();
expect(screen.getByText('Live')).toBeInTheDocument();
});
it('does not render "Live" badge when linked trip is in the past', async () => {
await renderAndWait();
expect(screen.queryByText('Live')).not.toBeInTheDocument();
});
});
// ── FE-PAGE-JOURNEYDETAIL-031 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-031: Synced with Trips badge renders', () => {
it('renders the "Synced with Trips" text in the hero', async () => {
it('renders the "Synced with Trips" text in the hero for live journeys', async () => {
setupDefaultHandlers({
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
});
await renderAndWait();
expect(screen.getByText('Synced with Trips')).toBeInTheDocument();
});
@@ -741,7 +752,7 @@ describe('JourneyDetailPage', () => {
it('shows the place count in the sidebar map', async () => {
await renderAndWait();
// The sidebar map shows "N Places" text
expect(screen.getByText(/Places/)).toBeInTheDocument();
expect(screen.getAllByText(/Places/).length).toBeGreaterThanOrEqual(1);
});
});
@@ -1106,8 +1117,9 @@ describe('JourneyDetailPage', () => {
// Map view renders a location list with entry titles/location names
// The MapView component shows entry names in clickable location items
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
expect(screen.getByText('Florence Day')).toBeInTheDocument();
// (timeline is still mounted but hidden, so multiple matches are expected)
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
});
});
@@ -1166,8 +1178,8 @@ describe('JourneyDetailPage', () => {
expect(dayBadges.length).toBeGreaterThanOrEqual(2);
// Each day group shows its entries
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
expect(screen.getByText('Florence Day')).toBeInTheDocument();
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
});
});
@@ -1717,7 +1729,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [emptyEntry],
stats: { entries: 1, photos: 0, cities: 1 },
stats: { entries: 1, photos: 0, places: 1 },
});
render(<JourneyDetailPage />);
@@ -1867,8 +1879,10 @@ describe('JourneyDetailPage', () => {
expect(screen.getAllByTestId('journey-map').length).toBeGreaterThanOrEqual(1);
});
// Click the "Arrived in Rome" location item
const romeItem = screen.getByText('Arrived in Rome');
// Click the "Arrived in Rome" location item in the map view's location list
// (timeline is still mounted but hidden, so find the one inside a cursor-pointer container)
const romeItems = screen.getAllByText('Arrived in Rome');
const romeItem = romeItems.find(el => el.closest('[class*="cursor-pointer"]')) ?? romeItems[0];
await user.click(romeItem);
// After clicking, the item should gain active styles (translate-x-0.5 on the container)
@@ -1930,7 +1944,7 @@ describe('JourneyDetailPage', () => {
{ ...mockJourneyDetail.entries[0], id: 10, entry_date: '2026-03-15' },
{ ...mockJourneyDetail.entries[1], id: 11, entry_date: '2026-03-15', location_lat: 41.95, location_lng: 12.55 },
];
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, cities: 2 } });
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, places: 2 } });
render(<JourneyDetailPage />);
await waitFor(() => {
@@ -2005,7 +2019,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [immichEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, cities: 2 },
stats: { entries: 2, photos: 1, places: 2 },
});
render(<JourneyDetailPage />);
@@ -2039,7 +2053,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [synologyEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, cities: 2 },
stats: { entries: 2, photos: 1, places: 2 },
});
render(<JourneyDetailPage />);
@@ -2636,7 +2650,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 5, cities: 2 },
stats: { entries: 2, photos: 5, places: 2 },
});
render(<JourneyDetailPage />);
@@ -2661,7 +2675,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [twoPhotoEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 2, cities: 2 },
stats: { entries: 2, photos: 2, places: 2 },
});
render(<JourneyDetailPage />);
@@ -3045,7 +3059,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [mockJourneyDetail.entries[0], noLocEntry],
stats: { entries: 2, photos: 1, cities: 1 },
stats: { entries: 2, photos: 1, places: 1 },
});
render(<JourneyDetailPage />);
@@ -3528,7 +3542,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [entryWithMultiPhotos, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 2, cities: 2 },
stats: { entries: 2, photos: 2, places: 2 },
});
server.use(
@@ -3620,7 +3634,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [mockJourneyDetail.entries[0], noTitleEntry],
stats: { entries: 2, photos: 1, cities: 2 },
stats: { entries: 2, photos: 1, places: 2 },
});
render(<JourneyDetailPage />);
+91 -22
View File
@@ -24,6 +24,7 @@ import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -163,6 +164,12 @@ export default function JourneyDetailPage() {
setActiveLocationId(id)
}, [])
useEffect(() => {
if (view === 'map') {
requestAnimationFrame(() => fullMapRef.current?.invalidateSize())
}
}, [view])
const mapEntries = useMemo(
() => (current?.entries || []).filter(e => e.location_lat && e.location_lng),
[current?.entries]
@@ -207,6 +214,14 @@ export default function JourneyDetailPage() {
const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort()
const tripDateMin = current.trips.length
? current.trips.reduce((min: string, t: any) => t.start_date && (!min || t.start_date < min) ? t.start_date : min, '')
: null
const tripDateMax = current.trips.length
? current.trips.reduce((max: string, t: any) => t.end_date && (!max || t.end_date > max) ? t.end_date : max, '')
: null
const lifecycle = computeJourneyLifecycle(current.status, tripDateMin || null, tripDateMax || null)
const showMobileCombined = isMobile && view === 'timeline'
return (
@@ -283,16 +298,28 @@ export default function JourneyDetailPage() {
<div className="relative z-[3] flex items-center justify-between mb-5">
{/* Desktop: badges */}
<div className="hidden md:flex items-center gap-2">
{current.status === 'active' && (
{lifecycle === 'live' && (
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-white/15 backdrop-blur rounded-full text-[10px] font-semibold uppercase">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
Live
{t('journey.frontpage.live')}
</div>
)}
{lifecycle !== 'archived' && current.trips.length > 0 && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
<RefreshCw size={11} />
{t('journey.detail.syncedWithTrips')}
</div>
)}
{lifecycle !== 'live' && lifecycle !== 'archived' && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
</div>
)}
{lifecycle === 'archived' && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
{t('journey.status.archived')}
</div>
)}
</div>
{/* Mobile: back button on the left */}
<button
@@ -331,7 +358,7 @@ export default function JourneyDetailPage() {
<div className="flex gap-8">
{[
{ value: sortedDates.length, label: t('journey.stats.days') },
{ value: current.stats.cities, label: t('journey.stats.cities') },
{ value: current.stats.places, label: t('journey.stats.places') },
{ value: current.stats.entries, label: t('journey.stats.entries') },
{ value: current.stats.photos, label: t('journey.stats.photos') },
].map(s => (
@@ -392,8 +419,8 @@ export default function JourneyDetailPage() {
</div>
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
{!isMobile && view === 'timeline' && (
<div className="flex flex-col gap-6 pb-24 md:pb-6">
{!isMobile && (
<div className={`flex flex-col gap-6 pb-24 md:pb-6${view === 'timeline' ? '' : ' hidden'}`}>
{sortedDates.length === 0 && (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
@@ -448,7 +475,7 @@ export default function JourneyDetailPage() {
)}
{/* Gallery View */}
{view === 'gallery' && (
<div className={view === 'gallery' ? '' : 'hidden'}>
<GalleryView
entries={current.entries}
journeyId={current.id}
@@ -457,17 +484,21 @@ export default function JourneyDetailPage() {
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onRefresh={() => loadJourney(Number(id))}
/>
)}
</div>
{/* Full Map View (desktop only — mobile uses combined view) */}
{!isMobile && view === 'map' && <div className="pb-24 md:pb-6"><MapView
{!isMobile && (
<div className={`pb-24 md:pb-6${view === 'map' ? '' : ' hidden'}`}>
<MapView
entries={current.entries}
mapEntries={mapEntries}
sortedDates={sortedDates}
activeLocationId={activeLocationId}
fullMapRef={fullMapRef}
onLocationClick={handleLocationClick}
/></div>}
/>
</div>
)}
</div>
{/* Right sidebar — hidden on mobile */}
@@ -494,7 +525,7 @@ export default function JourneyDetailPage() {
{ value: sortedDates.length, label: t('journey.stats.days') },
{ value: current.stats.entries, label: t('journey.stats.entries') },
{ value: current.stats.photos, label: t('journey.stats.photos') },
{ value: current.stats.cities, label: t('journey.stats.cities') },
{ value: current.stats.places, label: t('journey.stats.places') },
].map(s => (
<div key={s.label} className="rounded-lg bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-zinc-700/50 px-3 py-2.5">
<div className="text-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
@@ -1021,7 +1052,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
trips={trips}
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
onClose={() => setShowPicker(false)}
onAdd={async (assetIds, entryId) => {
onAdd={async (groups, entryId) => {
let targetId = entryId
if (!targetId) {
try {
@@ -1034,10 +1065,12 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
} catch { return }
}
let added = 0
for (const group of groups) {
try {
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds)
added = result.added || 0
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
added += result.added || 0
} catch {}
}
if (added > 0) {
toast.success(t('journey.photosAdded', { count: added }))
onRefresh()
@@ -1511,7 +1544,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
trips: JourneyTrip[]
existingAssetIds: Set<string>
onClose: () => void
onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
}) {
const { t } = useTranslation()
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
@@ -1525,7 +1558,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const [searchPage, setSearchPage] = useState(1)
const [searchFrom, setSearchFrom] = useState('')
const [searchTo, setSearchTo] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
@@ -1617,8 +1650,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const toggleAsset = (id: string) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
const next = new Map(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
}
return next
})
}
@@ -1780,9 +1817,9 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
<button
onClick={() => {
if (allSelected) {
setSelected(new Set())
setSelected(new Map())
} else {
setSelected(new Set(selectable.map((a: any) => a.id)))
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
}
}}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
@@ -1884,7 +1921,16 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{t('common.cancel')}
</button>
<button
onClick={() => onAdd([...selected], targetEntryId)}
onClick={() => {
const groupMap = new Map<string | undefined, string[]>()
for (const [assetId, { passphrase }] of selected.entries()) {
const list = groupMap.get(passphrase) || []
list.push(assetId)
groupMap.set(passphrase, list)
}
const groups = [...groupMap.entries()].map(([passphrase, assetIds]) => ({ assetIds, passphrase }))
onAdd(groups, targetEntryId)
}}
disabled={selected.size === 0}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
@@ -2091,7 +2137,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
return (
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center sm:justify-center sm:p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]">
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
@@ -2820,6 +2866,21 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
}
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [archiving, setArchiving] = useState(false)
const handleArchiveToggle = async () => {
setArchiving(true)
try {
const newStatus = journey.status === 'archived' ? 'active' : 'archived'
await updateJourney(journey.id, { status: newStatus })
toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened'))
onSaved()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setArchiving(false)
}
}
const handleDelete = async () => {
try {
@@ -2947,11 +3008,19 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
<div className="flex flex-wrap items-center gap-2 px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2 mr-auto"
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2"
>
<Trash2 size={13} />
{t('journey.settings.delete')}
</button>
<button
onClick={handleArchiveToggle}
disabled={archiving}
className="flex items-center gap-1.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg px-2.5 py-2 mr-auto disabled:opacity-40"
title={t('journey.settings.endDescription')}
>
{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
</button>
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
<button onClick={handleSave} disabled={saving || !title.trim()} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
{saving ? t('common.saving') : t('common.save')}
+16 -8
View File
@@ -43,7 +43,9 @@ function buildJourneyListItem(overrides: Record<string, unknown> = {}) {
status: 'draft' as const,
entry_count: 0,
photo_count: 0,
city_count: 0,
place_count: 0,
trip_date_min: null as string | null,
trip_date_max: null as string | null,
created_at: Date.now(),
updated_at: Date.now(),
...overrides,
@@ -194,7 +196,7 @@ describe('JourneyPage', () => {
// FE-PAGE-JOURNEY-008
it('FE-PAGE-JOURNEY-008: shows active journey hero when active journey exists', async () => {
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active' });
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
const other = buildJourneyListItem({ id: 11, title: 'Completed Trip', status: 'completed' });
setupDefaultHandlers([active, other]);
@@ -320,13 +322,13 @@ describe('JourneyPage', () => {
});
// FE-PAGE-JOURNEY-013
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/city counts', async () => {
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/place counts', async () => {
const j1 = buildJourneyListItem({
id: 20,
title: 'Stats Journey',
entry_count: 12,
photo_count: 47,
city_count: 5,
place_count: 5,
});
setupDefaultHandlers([j1]);
@@ -335,7 +337,7 @@ describe('JourneyPage', () => {
expect(screen.getByText('Stats Journey')).toBeInTheDocument();
});
// The card renders entry_count, photo_count, city_count values
// The card renders entry_count, photo_count, place_count values
expect(screen.getByText('12')).toBeInTheDocument();
expect(screen.getByText('47')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
@@ -361,6 +363,8 @@ describe('JourneyPage', () => {
id: 40,
title: 'Recent Active',
status: 'active',
trip_date_min: '2020-01-01',
trip_date_max: '2099-12-31',
updated_at: Date.now() - 60000, // 1 minute ago
});
setupDefaultHandlers([active]);
@@ -380,6 +384,8 @@ describe('JourneyPage', () => {
id: 41,
title: 'Hours Active',
status: 'active',
trip_date_min: '2020-01-01',
trip_date_max: '2099-12-31',
updated_at: Date.now() - 3 * 3600000, // 3 hours ago
});
setupDefaultHandlers([active]);
@@ -399,6 +405,8 @@ describe('JourneyPage', () => {
id: 42,
title: 'Days Active',
status: 'active',
trip_date_min: '2020-01-01',
trip_date_max: '2099-12-31',
updated_at: Date.now() - 5 * 24 * 3600000, // 5 days ago
});
setupDefaultHandlers([active]);
@@ -414,7 +422,7 @@ describe('JourneyPage', () => {
// FE-PAGE-JOURNEY-018
it('FE-PAGE-JOURNEY-018: active journey hero shows "Continue writing" button', async () => {
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active' });
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
setupDefaultHandlers([active]);
render(<JourneyPage />);
@@ -427,7 +435,7 @@ describe('JourneyPage', () => {
// FE-PAGE-JOURNEY-019
it('FE-PAGE-JOURNEY-019: active journey hero shows Live and Synced badges', async () => {
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active' });
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
setupDefaultHandlers([active]);
render(<JourneyPage />);
@@ -442,7 +450,7 @@ describe('JourneyPage', () => {
// FE-PAGE-JOURNEY-020
it('FE-PAGE-JOURNEY-020: clicking active journey hero navigates to its detail page', async () => {
const user = userEvent.setup();
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active' });
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
setupDefaultHandlers([active]);
render(<JourneyPage />);
+96 -19
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react'
import { useEffect, useState, useMemo, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore'
import { journeyApi } from '../api/client'
@@ -10,6 +10,7 @@ import {
Check, X, ChevronRight, RefreshCw, Users,
} from 'lucide-react'
import type { Journey } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -43,6 +44,9 @@ export default function JourneyPage() {
const [newTitle, setNewTitle] = useState('')
const [availableTrips, setAvailableTrips] = useState<any[]>([])
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
const [searchOpen, setSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
// suggestion
const [suggestions, setSuggestions] = useState<any[]>([])
@@ -56,12 +60,22 @@ export default function JourneyPage() {
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
const activeJourney = useMemo(() => {
return journeys.find(j => j.status === 'active') || null
}, [journeys])
if (searchQuery.trim()) return null
return journeys.find(j => {
const j2 = j as any
return computeJourneyLifecycle(j.status, j2.trip_date_min, j2.trip_date_max) === 'live'
}) || null
}, [journeys, searchQuery])
const otherJourneys = useMemo(() => {
return journeys.filter(j => j.id !== activeJourney?.id)
}, [journeys, activeJourney])
const filteredJourneys = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return journeys.filter(j => j.id !== activeJourney?.id)
return journeys.filter(j => {
const inTitle = j.title.toLowerCase().includes(q)
const inSubtitle = j.subtitle?.toLowerCase().includes(q) ?? false
return inTitle || inSubtitle
})
}, [journeys, activeJourney, searchQuery])
const openCreateModal = async (preSelectedTripId?: number) => {
setShowCreate(true)
@@ -99,16 +113,42 @@ export default function JourneyPage() {
<div style={{ paddingTop: 'var(--nav-h, 56px)' }}>
<div className="max-w-[1440px] mx-auto">
{/* Header — mobile: just a create button */}
<div className="md:hidden px-5 pt-5 pb-4">
{/* Header — mobile */}
<div className="md:hidden px-5 pt-5 pb-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<button
onClick={() => {
if (searchOpen) {
setSearchOpen(false)
setSearchQuery('')
} else {
setSearchOpen(true)
setTimeout(() => searchInputRef.current?.focus(), 50)
}
}}
className="w-10 h-10 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex-shrink-0"
>
{searchOpen ? <X size={15} /> : <Search size={15} />}
</button>
<button
onClick={() => openCreateModal()}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
>
<Plus size={16} strokeWidth={2.5} />
{t('journey.frontpage.createJourney')}
</button>
</div>
{searchOpen && (
<input
ref={searchInputRef}
value={searchQuery}
onChange={e => 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"
/>
)}
</div>
{/* Header — desktop */}
<div className="hidden md:flex items-start justify-between px-8 pt-10 pb-7">
@@ -117,8 +157,24 @@ export default function JourneyPage() {
<p className="text-[13px] text-zinc-500 mt-1.5">{t("journey.frontpage.subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<button className="w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700">
<Search size={15} />
{searchOpen && (
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') { setSearchQuery(''); setSearchOpen(false) } }}
placeholder={t('journey.search.placeholder')}
autoFocus
className="w-52 px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-[10px] text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-400 focus:outline-none"
/>
)}
<button
onClick={() => {
setSearchOpen(s => !s)
if (searchOpen) setSearchQuery('')
}}
className={`w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 flex items-center justify-center text-zinc-500 transition-colors ${searchOpen ? 'bg-zinc-100 dark:bg-zinc-700' : 'bg-white dark:bg-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-700'}`}
>
{searchOpen ? <X size={15} /> : <Search size={15} />}
</button>
<button
onClick={() => openCreateModal()}
@@ -226,7 +282,7 @@ export default function JourneyPage() {
{[
{ val: (activeJourney as any).entry_count ?? '--', label: t("journey.stats.entries") },
{ val: (activeJourney as any).photo_count ?? '--', label: t("journey.stats.photos") },
{ val: (activeJourney as any).city_count ?? '--', label: t("journey.stats.cities") },
{ val: (activeJourney as any).place_count ?? '--', label: t("journey.stats.places") },
].map(s => (
<div key={s.label} className="flex flex-col gap-1">
<span className="text-[28px] font-extrabold tracking-[-0.02em] leading-none">{s.val}</span>
@@ -243,11 +299,24 @@ export default function JourneyPage() {
</div>
)}
{/* Search results info */}
{searchQuery.trim() && (
<div className="mb-4 flex items-center gap-2">
<span className="text-[13px] text-zinc-500">
{filteredJourneys.length === 0
? t('journey.search.noResults', { query: searchQuery.trim() })
: `${filteredJourneys.length} ${t('journey.frontpage.journeys')}`}
</span>
</div>
)}
{/* All Journeys */}
{!searchQuery.trim() && (
<div className="mb-4 flex items-center justify-between">
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
</div>
)}
{loading && journeys.length === 0 ? (
<div className="flex justify-center py-16">
@@ -255,7 +324,7 @@ export default function JourneyPage() {
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-[18px]">
{otherJourneys.map(j => (
{filteredJourneys.map(j => (
<JourneyCard key={j.id} journey={j} onClick={() => navigate(`/journey/${j.id}`)} />
))}
@@ -279,7 +348,7 @@ export default function JourneyPage() {
{/* Create Modal */}
{showCreate && (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
{/* Header */}
<div className="px-7 pt-6 pb-5 border-b border-zinc-200 dark:border-zinc-700">
@@ -386,12 +455,13 @@ export default function JourneyPage() {
)
}
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; city_count?: number }; onClick: () => void }) {
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; place_count?: number; trip_date_min?: string | null; trip_date_max?: string | null }; onClick: () => void }) {
const { t } = useTranslation()
const j = journey
const entryCount = j.entry_count ?? 0
const photoCount = j.photo_count ?? 0
const cityCount = j.city_count ?? 0
const placeCount = j.place_count ?? 0
const lifecycle = computeJourneyLifecycle(j.status, j.trip_date_min, j.trip_date_max)
return (
<div
@@ -424,15 +494,22 @@ function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?:
{j.subtitle && (
<p className="text-[12px] text-zinc-500 mt-1">{j.subtitle}</p>
)}
{j.status === 'draft' && (
<span className="inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 uppercase tracking-wide">{t('journey.status.draft')}</span>
{lifecycle !== 'live' && (
<span className={`inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wide ${
lifecycle === 'archived' ? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500' :
lifecycle === 'upcoming' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' :
lifecycle === 'completed' ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' :
'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'
}`}>
{t(`journey.status.${lifecycle}`)}
</span>
)}
<div className="grid grid-cols-3 gap-2.5 mt-auto pt-3.5 border-t border-zinc-100 dark:border-zinc-800" style={{ marginTop: j.subtitle ? 14 : 'auto' }}>
{[
{ val: entryCount, label: t('journey.stats.entries') },
{ val: photoCount, label: t('journey.stats.photos') },
{ val: cityCount, label: t('journey.stats.cities') },
{ val: placeCount, label: t('journey.stats.places') },
].map(s => (
<div key={s.label} className="flex flex-col gap-1">
<span className={`text-[16px] font-bold leading-none tracking-[-0.01em] ${s.val > 0 ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`}>
+3 -3
View File
@@ -109,7 +109,7 @@ const mockJourneyData = {
stats: {
entries: 2,
photos: 1,
cities: 2,
places: 2,
},
};
@@ -354,7 +354,7 @@ describe('JourneyPublicPage', () => {
],
},
],
stats: { entries: 1, photos: 3, cities: 0 },
stats: { entries: 1, photos: 3, places: 0 },
};
server.use(
@@ -383,7 +383,7 @@ describe('JourneyPublicPage', () => {
it('FE-PAGE-PUBLICJOURNEY-015: stats display shows entries, photos, and cities counts', async () => {
const customData = {
...mockJourneyData,
stats: { entries: 14, photos: 83, cities: 7 },
stats: { entries: 14, photos: 83, places: 7 },
};
server.use(
http.get('/api/public/journey/test-share-token', () => HttpResponse.json(customData)),
+1 -1
View File
@@ -176,7 +176,7 @@ export default function JourneyPublicPage() {
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><Camera size={12} /> {stats.photos} {t('journey.stats.photos')}</span>
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.cities} {t('journey.stats.places')}</span>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.places} {t('journey.stats.places')}</span>
</div>
<div className="relative" style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('journey.public.readOnly')}</div>
+5 -3
View File
@@ -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'
@@ -141,6 +142,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)
@@ -261,7 +263,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}`
@@ -932,7 +934,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
{selectedPlace && isMobile && ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }} onClick={() => setSelectedPlaceId(null)}>
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', paddingBottom: 'var(--bottom-nav-h)' }} onClick={() => setSelectedPlaceId(null)}>
<div style={{ width: '100%', maxHeight: '85vh' }} onClick={e => e.stopPropagation()}>
<PlaceInspector
place={selectedPlace}
@@ -994,7 +996,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { 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} />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { 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} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { 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} />
}
</div>
+13
View File
@@ -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 }) => {
+12
View File
@@ -33,6 +33,9 @@ interface AuthState {
/** Server policy: all users must enable MFA */
appRequireMfa: boolean
tripRemindersEnabled: boolean
placesPhotosEnabled: boolean
placesAutocompleteEnabled: boolean
placesDetailsEnabled: boolean
login: (email: string, password: string) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
@@ -53,6 +56,9 @@ interface AuthState {
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
setTripRemindersEnabled: (val: boolean) => void
setPlacesPhotosEnabled: (val: boolean) => void
setPlacesAutocompleteEnabled: (val: boolean) => void
setPlacesDetailsEnabled: (val: boolean) => void
demoLogin: () => Promise<AuthResponse>
}
@@ -74,6 +80,9 @@ export const useAuthStore = create<AuthState>()(
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
placesPhotosEnabled: true,
placesAutocompleteEnabled: true,
placesDetailsEnabled: true,
login: async (email: string, password: string) => {
authSequence++
@@ -257,6 +266,9 @@ export const useAuthStore = create<AuthState>()(
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
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++
+2 -2
View File
@@ -8,7 +8,7 @@ export interface Journey {
subtitle?: string | null
cover_gradient?: string | null
cover_image?: string | null
status: 'draft' | 'active' | 'completed'
status: 'draft' | 'active' | 'completed' | 'archived'
created_at: number
updated_at: number
}
@@ -81,7 +81,7 @@ export interface JourneyDetail extends Journey {
entries: JourneyEntry[]
trips: JourneyTrip[]
contributors: JourneyContributor[]
stats: { entries: number; photos: number; cities: number }
stats: { entries: number; photos: number; places: number }
hide_skeletons?: boolean
}
+32
View File
@@ -0,0 +1,32 @@
export type JourneyLifecycle = 'archived' | 'live' | 'upcoming' | 'completed' | 'draft'
export function computeJourneyLifecycle(
status: string,
tripDateMin: string | null | undefined,
tripDateMax: string | null | undefined,
): JourneyLifecycle {
if (status === 'archived') return 'archived'
if (tripDateMin && tripDateMax) {
const today = new Date().toISOString().split('T')[0]
if (tripDateMin <= today && today <= tripDateMax) return 'live'
if (tripDateMin > today) return 'upcoming'
return 'completed'
}
if (!tripDateMin && !tripDateMax) {
return 'draft'
}
// Single boundary: only start or only end
if (tripDateMin && !tripDateMax) {
const today = new Date().toISOString().split('T')[0]
return tripDateMin > today ? 'upcoming' : 'live'
}
if (!tripDateMin && tripDateMax) {
const today = new Date().toISOString().split('T')[0]
return tripDateMax < today ? 'completed' : 'live'
}
return 'completed'
}
+19 -8
View File
@@ -69,10 +69,10 @@ There are **no database rows for notice definitions**. The registry is code-only
├── reads user_notice_dismissals
├── filters SYSTEM_NOTICES:
not dismissed
not expired (expiresAt)
within [minVersion, maxVersion) range for the running app version
all conditions pass (AND logic)
├── sorts by priority → severity → publishedAt (desc)
└── strips server-only fields (conditions, publishedAt, expiresAt, priority)
└── strips server-only fields (conditions, publishedAt, minVersion, maxVersion, priority)
6. Client receives SystemNoticeDTO[]
@@ -138,7 +138,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
**Never remove or renumber an entry. Never reuse an ID.**
Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, add `expiresAt` to stop it from being shown — do not delete the entry.
Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, set `maxVersion` to the upper version on which it should appear (e.g. `4.0.0` means show notice until `4.0.0` is reached) — do not delete the entry.
---
@@ -162,13 +162,16 @@ Dismissals are stored in the database keyed by `id`. Removing an entry means dis
| Field | Type | Description |
|---|---|---|
| `priority` | `number` | Higher number = shown first. Primary sort key. Default: `0`. |
| `expiresAt` | `string` | ISO 8601 date. Notice is automatically hidden after this date. Preferred over deleting entries. |
| `minVersion` | `string` | Lowest app version (inclusive, semver) that should show this notice. Omit for no lower bound. |
| `maxVersion` | `string` | Upper bound (exclusive, semver) — notice is hidden once this version ships. `maxVersion: '4.0.0'` means shown on `< 4.0.0`. Omit for no upper bound. |
| `icon` | `string` | Lucide icon name (e.g. `'Sparkles'`, `'ImageOff'`). Shown in the modal's severity icon circle. Falls back to the severity default icon if absent or unrecognised. |
| `bodyParams` | `Record<string, string>` | Interpolation parameters for `bodyKey`. Values replace `{key}` placeholders in the translated string. **Never hardcode version numbers or dates directly in translation strings — use this instead.** |
| `media` | `NoticeMedia` | Image to display in the modal. See below. |
| `highlights` | `Array<{ labelKey: string; iconName?: string }>` | Bullet-point feature list rendered below the body in modals. Each entry is a translation key + optional Lucide icon name. |
| `cta` | `NoticeCta` | Primary action button. See [§8 CTAs](#8-ctas-call-to-action). |
> **Version bounds:** The range is `[minVersion, maxVersion)` — lower bound inclusive, upper bound exclusive. So `maxVersion: '4.0.0'` hides the notice once the app reaches 4.0.0. Both bounds are compared after stripping prerelease/build metadata via `semver.coerce`, so a server running `3.0.0-pre.42` is treated as `3.0.0` — consistent with `existingUserBeforeVersion` and staging environments behave like production.
### `NoticeMedia`
```typescript
@@ -596,17 +599,25 @@ The registry integrity test will catch any `actionId` that appears in the regist
### Retire a notice (stop showing it)
**Do not delete the entry.** Set `expiresAt`:
**Do not delete the entry.** Set `maxVersion` to the last app version on which the notice should appear. Once the app is upgraded past that version, the service filters it out automatically. The database row for dismissed users remains harmless.
```typescript
{
id: 'old-campaign',
// ... all existing fields unchanged ...
expiresAt: '2026-07-01T00:00:00Z',
maxVersion: '3.1.0', // hidden once 3.1.0 ships (exclusive upper bound)
}
```
After the expiry date the service filters it out automatically. The database row for dismissed users remains harmless.
To scope a notice to a specific version window (e.g. a v3-only announcement), combine both bounds:
```typescript
{
id: 'v3-only',
minVersion: '3.0.0',
maxVersion: '4.0.0', // shown on >= 3.0.0 and < 4.0.0
}
```
---
@@ -751,4 +762,4 @@ cd client && npm run test -- SystemNoticeModal
| CTA labels ≤ 20 chars, sentence case, a verb | Consistent button copy across the app. |
| Priorities must be set explicitly for upgrade notices | Adjacent notices form a multipage group; ordering matters for the reading flow. |
| `action` CTA `actionId` must be registered client-side | The registry integrity test enforces this. Add both the registry entry and the `registerNoticeAction` call in the same PR. |
| `expiresAt` over deletion for retiring notices | See above. |
| `maxVersion` over deletion for retiring notices | See §12. Deletion would cause dismissed users to re-see the notice if the ID were ever reused. |
+49 -1
View File
@@ -1634,7 +1634,55 @@ 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: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
// 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/%')
)
`);
}},
// 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);
`),
// Migration 109: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS reservation_endpoints (
+1
View File
@@ -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 }) => {
+57
View File
@@ -201,6 +201,63 @@ 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);
});
// ── 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) => {
+4 -3
View File
@@ -115,13 +115,14 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { provider, asset_id, asset_ids, caption } = req.body || {};
const { provider, asset_id, asset_ids, caption, passphrase } = req.body || {};
const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
// Batch mode: { provider, asset_ids: string[] }
if (Array.isArray(asset_ids) && provider) {
const added: any[] = [];
for (const id of asset_ids) {
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption);
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption, pp);
if (photo) added.push(photo);
}
return res.status(201).json({ photos: added, added: added.length });
@@ -129,7 +130,7 @@ router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, re
// Single mode (backward compat)
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption, pp);
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
res.status(201).json(photo);
});
+29 -1
View File
@@ -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();
@@ -32,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;
@@ -70,11 +76,18 @@ 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;
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 +101,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 +121,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 };
+27 -8
View File
@@ -166,14 +166,9 @@ function startTripReminders(): void {
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
const hasEmail = activeChannels.includes('email') && !!(getSetting('smtp_host') || '').trim();
const hasWebhook = activeChannels.includes('webhook');
const channelReady = hasEmail || hasWebhook;
if (!channelReady || !reminderEnabled) {
if (!reminderEnabled) {
const { logInfo: li } = require('./services/auditLog');
const reason = !channelReady ? 'no notification channels configured' : 'trip reminders disabled in settings';
li(`Trip reminders: disabled (${reason})`);
li('Trip reminders: disabled in settings');
return;
}
@@ -253,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 };
+36
View File
@@ -459,6 +459,42 @@ 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 };
}
// ── 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;
+11 -2
View File
@@ -31,6 +31,7 @@ const ADMIN_SETTINGS_KEYS = [
'allow_registration', 'allowed_file_types', 'require_mfa',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
'notify_trip_reminder',
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
];
@@ -227,8 +228,13 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
const notifChannelsRaw = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channels'").get() as { value: string } | undefined)?.value || notifChannel;
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
const hasWebhookEnabled = activeChannels.includes('webhook');
const channelConfigured = (activeChannels.includes('email') && hasSmtpHost) || hasWebhookEnabled;
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
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 {
@@ -258,6 +264,9 @@ 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,
places_autocomplete_enabled: placesAutocompleteEnabled,
places_details_enabled: placesDetailsEnabled,
permissions: authenticatedUser ? getAllPermissions() : undefined,
dev_mode: process.env.NODE_ENV === 'development',
};
+12 -7
View File
@@ -1,7 +1,7 @@
import { db } from '../db/database';
import { broadcastToUser } from '../websocket';
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider } from './memories/photoResolverService';
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider, deleteTrekPhotoIfOrphan } from './memories/photoResolverService';
function ts(): number {
return Date.now();
@@ -59,12 +59,14 @@ export function listJourneys(userId: number) {
SELECT DISTINCT j.*,
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as city_count
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
FROM journeys j
LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ?
WHERE j.user_id = ? OR jc.user_id = ?
ORDER BY j.updated_at DESC
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; city_count: number })[];
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; place_count: number; trip_date_min: string | null; trip_date_max: string | null })[];
}
export function createJourney(userId: number, data: {
@@ -159,7 +161,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
// stats
const entryCount = entries.filter(e => e.type === 'entry').length;
const photoCount = photos.length;
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const userPrefs = db.prepare(
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
@@ -170,7 +172,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
entries: enrichedEntries,
trips,
contributors,
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
stats: { entries: entryCount, photos: photoCount, places: places.length },
hide_skeletons: !!(userPrefs?.hide_skeletons),
};
}
@@ -184,11 +186,13 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
}>): Journey | null {
if (!canEdit(journeyId, userId)) return null;
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, val] of Object.entries(data)) {
if (val !== undefined && allowed.includes(key)) {
if (key === 'status' && !ALLOWED_STATUSES.includes(val as string)) continue;
fields.push(`${key} = ?`);
values.push(val);
}
@@ -628,12 +632,12 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
}
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId);
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
// skip if already added
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
@@ -714,6 +718,7 @@ export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & {
if (!canEdit(photo.journey_id, userId)) return null;
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
deleteTrekPhotoIfOrphan(photo.photo_id);
// clean up empty Gallery entries left behind
const remaining = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(photo.entry_id);
+1 -1
View File
@@ -132,7 +132,7 @@ export function getPublicJourney(token: string) {
const stats = {
entries: entries.length,
photos: photos.length,
cities: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
};
return {
+154 -57
View File
@@ -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<Response> {
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<string, { photoUrl: string; attribution: string | null; fetchedAt: number; error?: boolean }>();
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<string, unknown> }> {
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,38 +579,51 @@ 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 };
}
photoCache.delete(placeId);
// 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
// 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) {
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
return wiki;
} else {
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
// 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 */ }
}
throw Object.assign(new Error('(Wikimedia) No photo available'), { status: 404 });
placePhotoCache.markError(placeId);
return null;
}
// Google Photos
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
// 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',
@@ -549,43 +633,56 @@ export async function getPlacePhoto(
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 });
placePhotoCache.markError(placeId);
return 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 });
placePhotoCache.markError(placeId);
return null;
}
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
const mediaRes = await fetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`,
// 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 mediaData = await mediaRes.json() as { photoUri?: string };
const photoUrl = mediaData.photoUri;
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 });
if (!mediaRes.ok) {
placePhotoCache.markError(placeId);
return null;
}
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
const bytes = Buffer.from(await mediaRes.arrayBuffer());
if (!bytes.length) {
placePhotoCache.markError(placeId);
return null;
}
// Persist photo URL to database
const cached = await placePhotoCache.put(placeId, bytes, attribution);
// 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(photoUrl, placeId, '');
'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);
}
return { photoUrl, attribution };
return { filePath: cached.filePath, 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 ────────────────────────────────────────────────────────
@@ -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,
@@ -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 ────────────────────────────────────────────────────
@@ -22,7 +23,7 @@ export function getOrCreateTrekPhoto(
).get(provider, assetId, ownerId) as { id: number } | undefined;
if (existing) {
if (passphrase) {
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ? AND passphrase IS NULL')
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ?')
.run(encrypt_api_key(passphrase), existing.id);
}
return existing.id;
@@ -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<unknown>,
): Promise<void> {
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;
}
@@ -145,6 +192,19 @@ export function setTrekPhotoProvider(
).run(provider, assetId, ownerId, trekPhotoId);
}
// ── Orphan cleanup ───────────────────────────────────────────────────────
export function deleteTrekPhotoIfOrphan(photoId: number): void {
const stillUsed = db.prepare(`
SELECT 1 FROM trip_photos WHERE photo_id = ?
UNION ALL
SELECT 1 FROM journey_photos WHERE photo_id = ?
LIMIT 1
`).get(photoId, photoId);
if (stillUsed) return;
db.prepare("DELETE FROM trek_photos WHERE id = ? AND provider != 'local'").run(photoId);
}
// ── Delete local file for a trek_photo ──────────────────────────────────
export function getTrekPhotoFilePath(photoId: number): string | null {
+45 -11
View File
@@ -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,
@@ -637,9 +678,10 @@ export async function streamSynologyAsset(
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
const resolvedSize = size || 'sm';
const params = kind === 'thumbnail'
? new URLSearchParams({
// Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files
// (original uses xl size to get a full-resolution JPEG-compatible render)
const resolvedSize = kind === 'original' ? 'xl' : (size || 'sm');
const params = new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
version: '2',
@@ -649,14 +691,6 @@ export async function streamSynologyAsset(
size: resolvedSize,
cache_key: parsedId.cacheKey,
_sid: sid.data,
})
: new URLSearchParams({
api: 'SYNO.Foto.Download',
method: 'download',
version: '2',
cache_key: parsedId.cacheKey,
unit_id: `[${parsedId.id}]`,
_sid: sid.data,
});
if (passphrase) params.append('passphrase', passphrase);
@@ -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<string, Promise<Buffer | null>>();
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<void> {
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<Buffer | null> | undefined {
return inFlight.get(key);
}
export function setInFlight(key: string, promise: Promise<Buffer | null>): 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);
}
}
@@ -8,7 +8,7 @@ import {
mapDbError,
Selection,
} from './helpersService';
import { getOrCreateTrekPhoto } from './photoResolverService';
import { getOrCreateTrekPhoto, deleteTrekPhotoIfOrphan } from './photoResolverService';
import { encrypt_api_key } from '../apiKeyCrypto';
@@ -212,6 +212,7 @@ export function removeTripPhoto(
AND photo_id = ?
`).run(tripId, userId, photoId);
deleteTrekPhotoIfOrphan(photoId);
broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
@@ -269,6 +270,9 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number):
}
try {
const linkedPhotos = db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.all(tripId, linkId) as Array<{ photo_id: number }>;
db.transaction(() => {
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.run(tripId, linkId);
@@ -276,6 +280,10 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number):
.run(linkId, tripId, userId);
})();
for (const { photo_id } of linkedPhotos) {
deleteTrekPhotoIfOrphan(photo_id);
}
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to remove album link');
+95
View File
@@ -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<string, Promise<{ filePath: string; attribution: string | null } | null>>();
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<CachedPhoto> {
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;
}
+10
View File
@@ -27,6 +27,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 90,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
@@ -55,6 +57,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
],
publishedAt: '2026-04-16T00:00:00Z',
priority: 80,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
@@ -78,6 +82,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
],
publishedAt: '2026-04-16T00:00:00Z',
priority: 75,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
@@ -98,6 +104,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 70,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
@@ -112,6 +120,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 95,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
// ── Onboarding ─────────────────────────────────────────────────────────────
+28 -4
View File
@@ -1,10 +1,34 @@
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';
import type { SystemNotice, 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';
}
}
export function isNoticeVersionActive(n: SystemNotice, currentAppVersion: string): boolean {
const appVersion = semver.coerce(currentAppVersion)?.version ?? '0.0.0';
if (n.minVersion !== undefined) {
const min = semver.valid(n.minVersion);
if (!min) { console.warn(`[systemNotices] "${n.id}" invalid minVersion "${n.minVersion}" — skipping`); return false; }
if (semver.lt(appVersion, min)) return false;
}
if (n.maxVersion !== undefined) {
const max = semver.valid(n.maxVersion);
if (!max) { console.warn(`[systemNotices] "${n.id}" invalid maxVersion "${n.maxVersion}" — skipping`); return false; }
if (semver.gte(appVersion, max)) return false;
}
return true;
}
function severityWeight(s: string): number {
@@ -35,7 +59,7 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
return SYSTEM_NOTICES
.filter(n => {
if (dismissedIds.has(n.id)) return false;
if (n.expiresAt && now > new Date(n.expiresAt)) return false;
if (!isNoticeVersionActive(n, currentAppVersion)) return false;
return evaluate(n, ctx);
})
.sort((a, b) => {
@@ -45,7 +69,7 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
if (sw !== 0) return sw;
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
})
.map(({ conditions: _c, publishedAt: _p, expiresAt: _e, priority: _pr, ...dto }) => dto);
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, ...dto }) => dto);
}
export function dismissNotice(userId: number, noticeId: string): boolean {
+3 -2
View File
@@ -37,9 +37,10 @@ export interface SystemNotice {
dismissible: boolean;
conditions: NoticeCondition[];
publishedAt: string;
expiresAt?: string;
minVersion?: string;
maxVersion?: string;
priority?: number;
}
// DTO sent to client (same shape minus the conditions — server evaluates those)
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'expiresAt' | 'priority'>;
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'minVersion' | 'maxVersion' | 'priority'>;
+1 -1
View File
@@ -329,7 +329,7 @@ export interface Journey {
subtitle?: string | null;
cover_gradient?: string | null;
cover_image?: string | null;
status: 'draft' | 'active' | 'completed';
status: 'draft' | 'active' | 'completed' | 'archived';
created_at: number;
updated_at: number;
}
+44
View File
@@ -936,6 +936,50 @@ describe('Share link update', () => {
});
// ─────────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// Provider photos passphrase (JOURNEY-INT-046, 047)
// ─────────────────────────────────────────────────────────────────────────────
describe('Provider photos — passphrase persistence', () => {
it('JOURNEY-INT-046 — single mode with passphrase persists encrypted passphrase on trek_photos', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-01' });
const res = await request(app)
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
.set('Cookie', authCookie(user.id))
.send({ provider: 'synologyphotos', asset_id: 'shared-asset-1', passphrase: 'pp-test' });
expect(res.status).toBe(201);
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
.get('synologyphotos', 'shared-asset-1', user.id) as { passphrase: string | null } | undefined;
expect(row?.passphrase).not.toBeNull();
expect(typeof row?.passphrase).toBe('string');
});
it('JOURNEY-INT-047 — batch mode with passphrase persists encrypted passphrase on all trek_photos rows', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-02' });
const res = await request(app)
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
.set('Cookie', authCookie(user.id))
.send({ provider: 'synologyphotos', asset_ids: ['batch-asset-1', 'batch-asset-2'], passphrase: 'pp-batch' });
expect(res.status).toBe(201);
expect(res.body.added).toBe(2);
for (const assetId of ['batch-asset-1', 'batch-asset-2']) {
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
.get('synologyphotos', assetId, user.id) as { passphrase: string | null } | undefined;
expect(row?.passphrase).not.toBeNull();
}
});
});
// Photo upload without files (JOURNEY-INT-045)
// ─────────────────────────────────────────────────────────────────────────────
@@ -166,16 +166,6 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
});
}
// Original download
if (apiName === 'SYNO.Foto.Download') {
const imageBytes = Buffer.from('fake-synology-original');
return Promise.resolve({
ok: true, status: 200,
headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null },
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
});
}
return Promise.reject(new Error(`Unexpected safeFetch call to Synology: ${u}, api=${apiName}`));
}
@@ -1179,3 +1169,74 @@ describe('Synology SSRF blocked error handling', () => {
expect(res.body.albums.length).toBeGreaterThan(0);
});
});
// ── Passphrase persistence fixes ─────────────────────────────────────────────
import { getOrCreateTrekPhoto, deleteTrekPhotoIfOrphan } from '../../src/services/memories/photoResolverService';
import { decrypt_api_key } from '../../src/services/apiKeyCrypto';
describe('trek_photos passphrase healing (SYNO-090)', () => {
it('SYNO-090 — getOrCreateTrekPhoto overwrites an existing bad passphrase when a new one is supplied', () => {
const { user } = createUser(testDb);
const wrongPass = 'wrong-passphrase';
const correctPass = 'correct-passphrase';
const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, wrongPass);
const row1 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id1) as { passphrase: string };
expect(decrypt_api_key(row1.passphrase)).toBe(wrongPass);
const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, correctPass);
expect(id2).toBe(id1);
const row2 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string };
expect(decrypt_api_key(row2.passphrase)).toBe(correctPass);
});
});
describe('trek_photos orphan cleanup (SYNO-091)', () => {
it('SYNO-091 — deleteTrekPhotoIfOrphan removes the trek_photos row when no trip_photos or journey_photos reference it', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
const trekPhotoId = getOrCreateTrekPhoto('synologyphotos', 'asset-orphan-test', user.id, 'pass-A');
testDb.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)'
).run(trip.id, user.id, trekPhotoId);
// Still referenced — must not be deleted.
deleteTrekPhotoIfOrphan(trekPhotoId);
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeDefined();
// Remove the reference, then orphan-cleanup should delete the trek_photos row.
testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(trekPhotoId);
deleteTrekPhotoIfOrphan(trekPhotoId);
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeUndefined();
});
it('SYNO-092 — re-adding a previously removed Synology photo stores the new passphrase correctly', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
const firstPass = 'first-passphrase';
const secondPass = 'second-passphrase';
// Add with wrong passphrase, then remove (simulating the bug scenario).
const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, firstPass);
testDb.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)'
).run(trip.id, user.id, id1);
testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(id1);
deleteTrekPhotoIfOrphan(id1);
// trek_photos row should be gone.
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(id1)).toBeUndefined();
// Re-add with the correct passphrase.
const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, secondPass);
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string };
expect(decrypt_api_key(row.passphrase)).toBe(secondPass);
});
});
@@ -107,9 +107,11 @@ describe('GET /api/system-notices/active', () => {
// welcome-v1 is also in the registry and matches firstLogin, so at least TEST_NOTICE is present
const testNotice = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id);
expect(testNotice).toBeDefined();
// DTO should not expose conditions, publishedAt, expiresAt, priority
// DTO should not expose conditions, publishedAt, minVersion, maxVersion, priority
expect(testNotice.conditions).toBeUndefined();
expect(testNotice.publishedAt).toBeUndefined();
expect(testNotice.minVersion).toBeUndefined();
expect(testNotice.maxVersion).toBeUndefined();
} finally {
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
@@ -202,7 +202,7 @@ describe('listJourneys', () => {
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Road Trip');
expect(result[0].entry_count).toBe(2);
expect(result[0].city_count).toBe(2);
expect(result[0].place_count).toBe(2);
});
it('JOURNEY-SVC-012: includes journeys where user is contributor', () => {
@@ -226,6 +226,21 @@ describe('listJourneys', () => {
expect(result).toHaveLength(0);
});
it('JOURNEY-SVC-013b: returns trip_date_min/max aggregated from linked trips', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Multi Trip' });
const trip1 = createTrip(testDb, user.id, { title: 'Trip A', start_date: '2025-06-01', end_date: '2025-06-10' });
const trip2 = createTrip(testDb, user.id, { title: 'Trip B', start_date: '2026-03-15', end_date: '2026-03-20' });
addTripToJourney(journey.id, trip1.id, user.id);
addTripToJourney(journey.id, trip2.id, user.id);
const result = listJourneys(user.id);
expect(result).toHaveLength(1);
expect(result[0].trip_date_min).toBe('2025-06-01');
expect(result[0].trip_date_max).toBe('2026-03-20');
});
});
describe('createJourney (service)', () => {
@@ -335,6 +350,26 @@ describe('updateJourney', () => {
expect(result).not.toBeNull();
expect(result!.title).toBe('Same');
});
it('JOURNEY-SVC-021b: accepts archived status', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'To Archive' });
const result = updateJourney(journey.id, user.id, { status: 'archived' });
expect(result).not.toBeNull();
expect(result!.status).toBe('archived');
});
it('JOURNEY-SVC-021c: ignores invalid status value', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Stay Active' });
const result = updateJourney(journey.id, user.id, { status: 'bogus' });
expect(result).not.toBeNull();
expect(result!.status).toBe('active');
});
});
describe('deleteJourney', () => {
@@ -1412,3 +1447,24 @@ describe('Edge cases', () => {
expect(filledRow.source_place_id).toBeNull();
});
});
// -- Passphrase on addProviderPhoto -------------------------------------------
describe('addProviderPhoto — passphrase', () => {
it('JOURNEY-SVC-088: addProviderPhoto with passphrase stores encrypted value on trek_photos', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-15' });
const photo = addProviderPhoto(entry.id, user.id, 'synologyphotos', 'pp-asset-1', undefined, 'secret-pp');
expect(photo).not.toBeNull();
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
.get('synologyphotos', 'pp-asset-1', user.id) as { passphrase: string | null } | undefined;
expect(row?.passphrase).not.toBeNull();
expect(typeof row?.passphrase).toBe('string');
// stored value must be encrypted (not plaintext)
expect(row?.passphrase).not.toBe('secret-pp');
});
});
@@ -336,7 +336,7 @@ describe('getPublicJourney', () => {
expect(result!.entries).toHaveLength(2);
expect(result!.stats.entries).toBe(2);
expect(result!.stats.photos).toBe(1);
expect(result!.stats.cities).toBe(2);
expect(result!.stats.places).toBe(2);
expect(result!.permissions.share_timeline).toBe(true);
expect(result!.permissions.share_gallery).toBe(true);
expect(result!.permissions.share_map).toBe(false);
@@ -397,6 +397,6 @@ describe('getPublicJourney', () => {
expect(result!.entries).toEqual([]);
expect(result!.stats.entries).toBe(0);
expect(result!.stats.photos).toBe(0);
expect(result!.stats.cities).toBe(0);
expect(result!.stats.places).toBe(0);
});
});
+99 -56
View File
@@ -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<any>) => 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({
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({
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();
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import semver from 'semver';
import { SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js';
/** Collect all actionIds registered via registerNoticeAction() in client source files. */
@@ -45,4 +46,21 @@ describe('registry integrity', () => {
expect(() => new Date(n.publishedAt).toISOString()).not.toThrow();
}
});
it('minVersion and maxVersion are valid semver when set, and minVersion <= maxVersion when both set', () => {
for (const n of SYSTEM_NOTICES) {
if (n.minVersion !== undefined) {
expect(semver.valid(n.minVersion), `notice "${n.id}" has invalid minVersion "${n.minVersion}"`).not.toBeNull();
}
if (n.maxVersion !== undefined) {
expect(semver.valid(n.maxVersion), `notice "${n.id}" has invalid maxVersion "${n.maxVersion}"`).not.toBeNull();
}
if (n.minVersion && n.maxVersion) {
expect(
semver.lte(n.minVersion, n.maxVersion),
`notice "${n.id}": minVersion ${n.minVersion} > maxVersion ${n.maxVersion}`
).toBe(true);
}
}
});
});
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { isNoticeVersionActive } from '../../../src/systemNotices/service.js';
import type { SystemNotice } from '../../../src/systemNotices/types.js';
const base: SystemNotice = {
id: 'test-notice',
display: 'modal',
severity: 'info',
titleKey: 'k',
bodyKey: 'k',
dismissible: true,
conditions: [],
publishedAt: '2026-01-01T00:00:00Z',
};
describe('isNoticeVersionActive', () => {
it('passes when no bounds are set', () => {
expect(isNoticeVersionActive(base, '3.5.0')).toBe(true);
});
it('passes when app version equals minVersion (inclusive)', () => {
expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0' }, '3.0.0')).toBe(true);
});
it('fails when app version is below minVersion', () => {
expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0' }, '2.9.9')).toBe(false);
});
it('fails when app version equals maxVersion (exclusive upper bound)', () => {
expect(isNoticeVersionActive({ ...base, maxVersion: '3.0.0' }, '3.0.0')).toBe(false);
});
it('fails when app version exceeds maxVersion', () => {
expect(isNoticeVersionActive({ ...base, maxVersion: '3.0.0' }, '3.0.1')).toBe(false);
});
it('passes when app version is just below maxVersion', () => {
expect(isNoticeVersionActive({ ...base, maxVersion: '3.0.0' }, '2.9.9')).toBe(true);
});
it('passes when app version is inside [minVersion, maxVersion)', () => {
expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0', maxVersion: '3.9.9' }, '3.5.2')).toBe(true);
});
it('treats prerelease app version as base release (semver.coerce)', () => {
expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0' }, '3.0.0-pre.42')).toBe(true);
});
it('skips notice and returns false when minVersion is invalid semver', () => {
expect(isNoticeVersionActive({ ...base, minVersion: 'not-semver' }, '3.0.0')).toBe(false);
});
it('skips notice and returns false when maxVersion is invalid semver', () => {
expect(isNoticeVersionActive({ ...base, maxVersion: 'garbage' }, '3.0.0')).toBe(false);
});
});