diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 7489ef64..26b11172 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -34,7 +34,12 @@ function escAttr(s) { return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') } +const iconCache = new Map() + function createPlaceIcon(place, orderNumbers, isSelected) { + const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}` + const cached = iconCache.get(cacheKey) + if (cached) return cached const size = isSelected ? 44 : 36 const borderColor = isSelected ? '#111827' : 'white' const borderWidth = isSelected ? 3 : 2.5 @@ -42,9 +47,8 @@ function createPlaceIcon(place, orderNumbers, isSelected) { ? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)' : '0 2px 8px rgba(0,0,0,0.22)' const bgColor = place.category_color || '#6b7280' - const icon = place.category_icon || '📍' - // Number badges (bottom-right), supports multiple numbers for duplicate places + // Number badges (bottom-right) let badgeHtml = '' if (orderNumbers && orderNumbers.length > 0) { const label = orderNumbers.join(' · ') @@ -62,28 +66,30 @@ function createPlaceIcon(place, orderNumbers, isSelected) { ">${label}` } - if (place.image_url) { - return L.divIcon({ + // 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:')) { + const imgIcon = L.divIcon({ className: '', html: `
-
- -
+ ${badgeHtml}
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], tooltipAnchor: [size / 2 + 6, 0], }) + iconCache.set(cacheKey, imgIcon) + return imgIcon } - return L.divIcon({ + const fallbackIcon = L.divIcon({ className: '', html: `
${categoryIconSvg(place.category_icon, isSelected ? 18 : 15)} ${badgeHtml} @@ -100,6 +107,8 @@ function createPlaceIcon(place, orderNumbers, isSelected) { iconAnchor: [size / 2, size / 2], tooltipAnchor: [size / 2 + 6, 0], }) + iconCache.set(cacheKey, fallbackIcon) + return fallbackIcon } interface SelectionControllerProps { @@ -174,6 +183,16 @@ interface MapClickHandlerProps { onClick: ((e: L.LeafletMouseEvent) => void) | null } +function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZoomEnd: () => void }) { + const map = useMap() + useEffect(() => { + map.on('zoomstart', onZoomStart) + map.on('zoomend', onZoomEnd) + return () => { map.off('zoomstart', onZoomStart); map.off('zoomend', onZoomEnd) } + }, [map, onZoomStart, onZoomEnd]) + return null +} + function MapClickHandler({ onClick }: MapClickHandlerProps) { const map = useMap() useEffect(() => { @@ -245,8 +264,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) { } // Module-level photo cache shared with PlaceAvatar -const mapPhotoCache = new Map() -const mapPhotoInFlight = new Set() +import { getCached, isLoading, fetchPhoto, onPhotoLoaded, onThumbReady, getAllThumbs } from '../../services/photoService' // Live location tracker — blue dot with pulse animation (like Apple/Google Maps) function LocationTracker() { @@ -366,51 +384,46 @@ export const MapView = memo(function MapView({ const right = rightWidth + 40 return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] } }, [leftWidth, rightWidth, hasInspector]) - const [photoUrls, setPhotoUrls] = useState({}) - // Fetch photos for places with concurrency limit to avoid blocking map rendering + // photoUrls: only base64 thumbs for smooth map zoom + const [photoUrls, setPhotoUrls] = useState>(getAllThumbs) + + // Fetch photos via shared service — subscribe to thumb (base64) availability + const placeIds = useMemo(() => places.map(p => p.id).join(','), [places]) useEffect(() => { - const queue = places.filter(place => { - if (place.image_url) return false + if (!places || places.length === 0) 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) continue const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` - if (!cacheKey) return false - if (mapPhotoCache.has(cacheKey)) { - const cached = mapPhotoCache.get(cacheKey) - if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached })) - return false + if (!cacheKey) continue + + const cached = getCached(cacheKey) + if (cached?.thumbDataUrl) { + setThumb(cacheKey, cached.thumbDataUrl) + continue } - if (mapPhotoInFlight.has(cacheKey)) return false - const photoId = place.google_place_id || place.osm_id - if (!photoId && !(place.lat && place.lng)) return false - return true - }) - let active = 0 - const MAX_CONCURRENT = 3 - let idx = 0 + // Subscribe for when thumb becomes available + cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb))) - const fetchNext = () => { - while (active < MAX_CONCURRENT && idx < queue.length) { - const place = queue[idx++] - const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` + // Start fetch if not yet started + if (!cached && !isLoading(cacheKey)) { const photoId = place.google_place_id || place.osm_id - mapPhotoInFlight.add(cacheKey) - active++ - mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) - .then(data => { - if (data.photoUrl) { - mapPhotoCache.set(cacheKey, data.photoUrl) - setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl })) - } else { - mapPhotoCache.set(cacheKey, null) - } - }) - .catch(() => { mapPhotoCache.set(cacheKey, null) }) - .finally(() => { mapPhotoInFlight.delete(cacheKey); active--; fetchNext() }) + if (photoId || (place.lat && place.lng)) { + fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) + } } } - fetchNext() - }, [places]) + + return () => cleanups.forEach(fn => fn()) + }, [placeIds]) const clusterIconCreateFunction = useCallback((cluster) => { const count = cluster.getChildCount() @@ -426,10 +439,10 @@ export const MapView = memo(function MapView({ const markers = useMemo(() => places.map((place) => { const isSelected = place.id === selectedPlaceId - const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` - const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null + const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` + const resolvedPhoto = place.image_url || (pck && photoUrls[pck]) || null const orderNumbers = dayOrderMap[place.id] ?? null - const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected) + const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected) return ( @@ -497,12 +513,14 @@ export const MapView = memo(function MapView({ {markers} diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 38c61ffd..2c85a4f5 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -96,7 +96,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const { t, language, locale } = useTranslation() const ctxMenu = useContextMenu() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' - const tripStore = useTripStore() + const tripActions = useRef(useTripStore.getState()).current const can = useCanDo() const canEditDays = can('day_edit', trip) @@ -425,7 +425,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ try { if (assignmentIds.length) await onReorder(dayId, assignmentIds) for (const n of noteUpdates) { - await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order }) + await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order }) } if (transportUpdates.length) { for (const tu of transportUpdates) { @@ -518,7 +518,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ currentAssignments[key] = currentAssignments[key].map(a => a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a ) - tripStore.setAssignments(currentAssignments) + tripActions.setAssignments(currentAssignments) } } catch (err) { toast.error(err instanceof Error ? err.message : 'Unknown error') @@ -653,9 +653,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ if (placeId) { onAssignToDay?.(parseInt(placeId), dayId) } else if (assignmentId && fromDayId !== dayId) { - tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (noteId && fromDayId !== dayId) { - tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } setDraggingId(null) setDropTargetKey(null) @@ -911,11 +911,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) } else if (assignmentId && fromDayId !== day.id) { - tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (assignmentId) { handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId) } else if (noteId && fromDayId !== day.id) { - tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId) } @@ -929,11 +929,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ setDropTargetKey(null); window.__dragData = null; return } if (assignmentId && fromDayId !== day.id) { - tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (noteId && fromDayId !== day.id) { - tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } const m = getMergedItems(day.id) @@ -1028,7 +1028,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ setDropTargetKey(null); window.__dragData = null } else if (fromAssignmentId && fromDayId !== day.id) { const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id) - tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id) @@ -1036,7 +1036,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const tm = getMergedItems(day.id) const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id) const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 - tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id) @@ -1121,10 +1121,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ )}
{(place.description || place.address || cat?.name) && ( -
- - {place.description || place.address || cat?.name} - +
+ {place.description || place.address || cat?.name || ''}
)} {(() => { @@ -1217,11 +1215,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) } else if (fromAssignmentId && fromDayId !== day.id) { - tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id) } else if (noteId && fromDayId !== day.id) { - tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id) } @@ -1290,7 +1288,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const tm = getMergedItems(day.id) const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 - tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null) } else if (fromNoteId && fromNoteId !== String(note.id)) { handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id) @@ -1298,7 +1296,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const tm = getMergedItems(day.id) const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length - tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null) } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id) @@ -1363,11 +1361,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ } if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return } if (assignmentId && fromDayId !== day.id) { - tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (noteId && fromDayId !== day.id) { - tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } const m = getMergedItems(day.id) @@ -1618,7 +1616,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ {/* Dateien */} {(() => { - const resFiles = (tripStore.files || []).filter(f => + const resFiles = (useTripStore.getState().files || []).filter(f => !f.deleted_at && ( f.reservation_id === res.id || (f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id)) diff --git a/client/src/components/shared/PlaceAvatar.tsx b/client/src/components/shared/PlaceAvatar.tsx index ba682cf4..43d8a4fd 100644 --- a/client/src/components/shared/PlaceAvatar.tsx +++ b/client/src/components/shared/PlaceAvatar.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect } from 'react' -import { mapsApi } from '../../api/client' +import React, { useState, useEffect, useRef } from 'react' import { getCategoryIcon } from './categoryIcons' +import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService' import type { Place } from '../../types' interface Category { @@ -14,57 +14,52 @@ interface PlaceAvatarProps { category?: Category | null } -const photoCache = new Map() -const photoInFlight = new Set() -// Event-based notification instead of polling intervals -const photoListeners = new Map void>>() - -function notifyListeners(key: string, url: string | null) { - const listeners = photoListeners.get(key) - if (listeners) { - listeners.forEach(fn => fn(url)) - photoListeners.delete(key) - } -} - export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) { const [photoSrc, setPhotoSrc] = useState(place.image_url || null) + const [visible, setVisible] = useState(false) + const ref = useRef(null) + + // Observe visibility — fetch photo only when avatar enters viewport + useEffect(() => { + if (place.image_url) { setVisible(true); return } + const el = ref.current + if (!el) return + // Check if already cached — show immediately without waiting for intersection + const photoId = place.google_place_id || place.osm_id + const cacheKey = photoId || `${place.lat},${place.lng}` + if (cacheKey && getCached(cacheKey)) { setVisible(true); return } + + const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '200px' }) + io.observe(el) + return () => io.disconnect() + }, [place.id]) useEffect(() => { + if (!visible) return if (place.image_url) { setPhotoSrc(place.image_url); return } const photoId = place.google_place_id || place.osm_id if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return } const cacheKey = photoId || `${place.lat},${place.lng}` - if (photoCache.has(cacheKey)) { - const cached = photoCache.get(cacheKey) - if (cached) setPhotoSrc(cached) + + const cached = getCached(cacheKey) + if (cached) { + setPhotoSrc(cached.thumbDataUrl || cached.photoUrl) + if (!cached.thumbDataUrl && cached.photoUrl) { + return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb)) + } return } - if (photoInFlight.has(cacheKey)) { - // Subscribe to notification instead of polling - if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set()) - const handler = (url: string | null) => { if (url) setPhotoSrc(url) } - photoListeners.get(cacheKey)!.add(handler) - return () => { photoListeners.get(cacheKey)?.delete(handler) } + if (isLoading(cacheKey)) { + return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb)) } - photoInFlight.add(cacheKey) - mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) - .then((data: { photoUrl?: string }) => { - const url = data.photoUrl || null - photoCache.set(cacheKey, url) - if (url) setPhotoSrc(url) - notifyListeners(cacheKey, url) - photoInFlight.delete(cacheKey) - }) - .catch(() => { - photoCache.set(cacheKey, null) - notifyListeners(cacheKey, null) - photoInFlight.delete(cacheKey) - }) - }, [place.id, place.image_url, place.google_place_id, place.osm_id]) + fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name, + entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) } + ) + return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb)) + }, [visible, place.id, place.image_url, place.google_place_id, place.osm_id]) const bgColor = category?.color || '#6366f1' const IconComp = getCategoryIcon(category?.icon) @@ -81,11 +76,10 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P if (photoSrc) { return ( -
+
{place.name} setPhotoSrc(null)} @@ -95,7 +89,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P } return ( -
+
) diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index 2cd5c0e1..e78c3fb6 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -19,7 +19,8 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu const updateRouteForDay = useCallback(async (dayId: number | null) => { if (routeAbortRef.current) routeAbortRef.current.abort() if (!dayId) { setRoute(null); setRouteSegments([]); return } - const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) + const currentAssignments = tripStore.assignments || {} + const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng) if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return } setRoute(waypoints.map((p) => [p.lat!, p.lng!])) @@ -33,12 +34,14 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([]) else if (!(err instanceof Error)) setRouteSegments([]) } - }, [tripStore, routeCalcEnabled]) + }, [routeCalcEnabled]) + // Only recalculate when assignments for the SELECTED day change + const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null useEffect(() => { if (!selectedDayId) { setRoute(null); setRouteSegments([]); return } updateRouteForDay(selectedDayId) - }, [selectedDayId, tripStore.assignments]) + }, [selectedDayId, selectedDayAssignments]) return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } } diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index a68fe291..1b08bf87 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -248,6 +248,7 @@ const ar: Record = { 'settings.roleAdmin': 'مسؤول', 'settings.oidcLinked': 'مرتبط مع', 'settings.changePassword': 'تغيير كلمة المرور', + 'settings.mustChangePassword': 'يجب عليك تغيير كلمة المرور قبل المتابعة. يرجى تعيين كلمة مرور جديدة أدناه.', 'settings.currentPassword': 'كلمة المرور الحالية', 'settings.currentPasswordRequired': 'كلمة المرور الحالية مطلوبة', 'settings.newPassword': 'كلمة المرور الجديدة', @@ -695,7 +696,6 @@ const ar: Record = { 'atlas.statsTab': 'الإحصائيات', 'atlas.bucketTab': 'قائمة الأمنيات', 'atlas.addBucket': 'إضافة إلى قائمة الأمنيات', - 'atlas.bucketNamePlaceholder': 'مكان أو وجهة...', 'atlas.bucketNotesPlaceholder': 'ملاحظات (اختياري)', 'atlas.bucketEmpty': 'قائمة أمنياتك فارغة', 'atlas.bucketEmptyHint': 'أضف أماكن تحلم بزيارتها', @@ -708,7 +708,6 @@ const ar: Record = { 'atlas.nextTrip': 'الرحلة القادمة', 'atlas.daysLeft': 'يوم متبقٍ', 'atlas.streak': 'سلسلة', - 'atlas.year': 'سنة', 'atlas.years': 'سنوات', 'atlas.yearInRow': 'سنة متتالية', 'atlas.yearsInRow': 'سنوات متتالية', @@ -738,6 +737,7 @@ const ar: Record = { 'trip.tabs.budget': 'الميزانية', 'trip.tabs.files': 'الملفات', 'trip.loading': 'جارٍ تحميل الرحلة...', + 'trip.loadingPhotos': 'جارٍ تحميل صور الأماكن...', 'trip.mobilePlan': 'الخطة', 'trip.mobilePlaces': 'الأماكن', 'trip.toast.placeUpdated': 'تم تحديث المكان', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 044c39fb..054ae5f5 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -294,6 +294,7 @@ const br: Record = { 'settings.mcp.toast.createError': 'Falha ao criar token', 'settings.mcp.toast.deleted': 'Token excluído', 'settings.mcp.toast.deleteError': 'Falha ao excluir token', + 'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.', // Login 'login.error': 'Falha no login. Verifique suas credenciais.', @@ -503,11 +504,13 @@ const br: Record = { 'admin.addons.disabled': 'Desativado', 'admin.addons.type.trip': 'Viagem', 'admin.addons.type.global': 'Global', + 'admin.addons.type.integration': 'Integração', 'admin.addons.tripHint': 'Disponível como aba em cada viagem', 'admin.addons.globalHint': 'Disponível como seção própria na navegação principal', 'admin.addons.toast.updated': 'Complemento atualizado', 'admin.addons.toast.error': 'Falha ao atualizar complemento', 'admin.addons.noAddons': 'Nenhum complemento disponível', + 'admin.addons.integrationHint': 'Serviços de backend e integrações de API sem página dedicada', // Weather info 'admin.weather.title': 'Dados meteorológicos', 'admin.weather.badge': 'Desde 24 de março de 2026', @@ -675,7 +678,6 @@ const br: Record = { 'atlas.statsTab': 'Estatísticas', 'atlas.bucketTab': 'Lista de desejos', 'atlas.addBucket': 'Adicionar à lista de desejos', - 'atlas.bucketNamePlaceholder': 'Lugar ou destino...', 'atlas.bucketNotesPlaceholder': 'Notas (opcional)', 'atlas.bucketEmpty': 'Sua lista de desejos está vazia', 'atlas.bucketEmptyHint': 'Adicione lugares que sonha em visitar', @@ -688,7 +690,6 @@ const br: Record = { 'atlas.nextTrip': 'Próxima viagem', 'atlas.daysLeft': 'dias restantes', 'atlas.streak': 'Sequência', - 'atlas.year': 'ano', 'atlas.years': 'anos', 'atlas.yearInRow': 'ano seguido', 'atlas.yearsInRow': 'anos seguidos', @@ -730,6 +731,7 @@ const br: Record = { 'trip.toast.reservationAdded': 'Reserva adicionada', 'trip.toast.deleted': 'Excluído', 'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?', + 'trip.loadingPhotos': 'Carregando fotos dos lugares...', // Day Plan Sidebar 'dayplan.emptyDay': 'Nenhum lugar planejado para este dia', @@ -1414,6 +1416,20 @@ const br: Record = { // Permissions 'admin.tabs.permissions': 'Permissões', + 'admin.tabs.mcpTokens': 'Tokens MCP', + 'admin.mcpTokens.title': 'Tokens MCP', + 'admin.mcpTokens.subtitle': 'Gerenciar tokens de API de todos os usuários', + 'admin.mcpTokens.owner': 'Proprietário', + 'admin.mcpTokens.tokenName': 'Nome do Token', + 'admin.mcpTokens.created': 'Criado', + 'admin.mcpTokens.lastUsed': 'Último uso', + 'admin.mcpTokens.never': 'Nunca', + 'admin.mcpTokens.empty': 'Nenhum token MCP foi criado ainda', + 'admin.mcpTokens.deleteTitle': 'Excluir Token', + 'admin.mcpTokens.deleteMessage': 'Isso revogará o token imediatamente. O usuário perderá o acesso MCP por este token.', + 'admin.mcpTokens.deleteSuccess': 'Token excluído', + 'admin.mcpTokens.deleteError': 'Falha ao excluir token', + 'admin.mcpTokens.loadError': 'Falha ao carregar tokens', 'perm.title': 'Configurações de Permissões', 'perm.subtitle': 'Controle quem pode realizar ações no aplicativo', 'perm.saved': 'Configurações de permissões salvas', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 996edb41..26f0c338 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -695,7 +695,6 @@ const cs: Record = { 'atlas.statsTab': 'Statistiky', 'atlas.bucketTab': 'Bucket List', 'atlas.addBucket': 'Přidat na Bucket List', - 'atlas.bucketNamePlaceholder': 'Místo nebo destinace...', 'atlas.bucketNotesPlaceholder': 'Poznámky (volitelné)', 'atlas.bucketEmpty': 'Váš seznam přání je prázdný', 'atlas.bucketEmptyHint': 'Přidejte místa, která sníte navštívit', @@ -738,6 +737,7 @@ const cs: Record = { 'trip.tabs.budget': 'Rozpočet', 'trip.tabs.files': 'Soubory', 'trip.loading': 'Načítání cesty...', + 'trip.loadingPhotos': 'Načítání fotek míst...', 'trip.mobilePlan': 'Plán', 'trip.mobilePlaces': 'Místa', 'trip.toast.placeUpdated': 'Místo bylo aktualizováno', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 09139b3c..0c32841b 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -243,6 +243,7 @@ const de: Record = { 'settings.roleAdmin': 'Administrator', 'settings.oidcLinked': 'Verknüpft mit', 'settings.changePassword': 'Passwort ändern', + 'settings.mustChangePassword': 'Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können. Bitte legen Sie unten ein neues Passwort fest.', 'settings.currentPassword': 'Aktuelles Passwort', 'settings.currentPasswordRequired': 'Aktuelles Passwort wird benötigt', 'settings.newPassword': 'Neues Passwort', @@ -693,7 +694,6 @@ const de: Record = { 'atlas.statsTab': 'Statistik', 'atlas.bucketTab': 'Bucket List', 'atlas.addBucket': 'Zur Bucket List hinzufügen', - 'atlas.bucketNamePlaceholder': 'Ort oder Reiseziel...', 'atlas.bucketNotesPlaceholder': 'Notizen (optional)', 'atlas.bucketEmpty': 'Deine Bucket List ist leer', 'atlas.bucketEmptyHint': 'Füge Orte hinzu, die du besuchen möchtest', @@ -706,7 +706,6 @@ const de: Record = { 'atlas.nextTrip': 'Nächster Trip', 'atlas.daysLeft': 'Tage', 'atlas.streak': 'Streak', - 'atlas.year': 'Jahr', 'atlas.years': 'Jahre', 'atlas.yearInRow': 'Jahr in Folge', 'atlas.yearsInRow': 'Jahre in Folge', @@ -736,6 +735,7 @@ const de: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Dateien', 'trip.loading': 'Reise wird geladen...', + 'trip.loadingPhotos': 'Fotos der Orte werden geladen...', 'trip.mobilePlan': 'Planung', 'trip.mobilePlaces': 'Orte', 'trip.toast.placeUpdated': 'Ort aktualisiert', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 2f2231f9..d20130d4 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -732,6 +732,7 @@ const en: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Files', 'trip.loading': 'Loading trip...', + 'trip.loadingPhotos': 'Loading place photos...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Places', 'trip.toast.placeUpdated': 'Place updated', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index be7cf014..9c0dedce 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -244,6 +244,7 @@ const es: Record = { 'settings.roleAdmin': 'Administrador', 'settings.oidcLinked': 'Vinculado con', 'settings.changePassword': 'Cambiar contraseña', + 'settings.mustChangePassword': 'Debe cambiar su contraseña antes de continuar. Establezca una nueva contraseña a continuación.', 'settings.currentPassword': 'Contraseña actual', 'settings.newPassword': 'Nueva contraseña', 'settings.confirmPassword': 'Confirmar nueva contraseña', @@ -697,9 +698,7 @@ const es: Record = { 'atlas.addToBucket': 'Añadir a lista de deseos', 'atlas.addPoi': 'Añadir lugar', 'atlas.searchCountry': 'Buscar un país...', - 'atlas.bucketNamePlaceholder': 'Nombre (país, ciudad, lugar…)', 'atlas.month': 'Mes', - 'atlas.year': 'Año', 'atlas.addToBucketHint': 'Guardar como lugar que quieres visitar', 'atlas.bucketWhen': '¿Cuándo planeas visitarlo?', @@ -712,6 +711,7 @@ const es: Record = { 'trip.tabs.budget': 'Presupuesto', 'trip.tabs.files': 'Archivos', 'trip.loading': 'Cargando viaje...', + 'trip.loadingPhotos': 'Cargando fotos de los lugares...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Lugares', 'trip.toast.placeUpdated': 'Lugar actualizado', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index eb7d0ad7..d0caa3af 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -243,6 +243,7 @@ const fr: Record = { 'settings.roleAdmin': 'Administrateur', 'settings.oidcLinked': 'Lié avec', 'settings.changePassword': 'Changer le mot de passe', + 'settings.mustChangePassword': 'Vous devez changer votre mot de passe avant de continuer. Veuillez définir un nouveau mot de passe ci-dessous.', 'settings.currentPassword': 'Mot de passe actuel', 'settings.currentPasswordRequired': 'Le mot de passe actuel est requis', 'settings.newPassword': 'Nouveau mot de passe', @@ -720,9 +721,7 @@ const fr: Record = { 'atlas.addToBucket': 'Ajouter à la bucket list', 'atlas.addPoi': 'Ajouter un lieu', 'atlas.searchCountry': 'Rechercher un pays…', - 'atlas.bucketNamePlaceholder': 'Nom (pays, ville, lieu…)', 'atlas.month': 'Mois', - 'atlas.year': 'Année', 'atlas.addToBucketHint': 'Sauvegarder comme lieu à visiter', 'atlas.bucketWhen': 'Quand prévoyez-vous d\'y aller ?', @@ -735,6 +734,7 @@ const fr: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Fichiers', 'trip.loading': 'Chargement du voyage…', + 'trip.loadingPhotos': 'Chargement des photos des lieux...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Lieux', 'trip.toast.placeUpdated': 'Lieu mis à jour', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 1cbb682a..34589ce3 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -246,6 +246,7 @@ const hu: Record = { 'settings.mfa.toastEnabled': 'Kétfaktoros hitelesítés engedélyezve', 'settings.mfa.toastDisabled': 'Kétfaktoros hitelesítés kikapcsolva', 'settings.mfa.demoBlocked': 'Demo módban nem érhető el', + 'settings.mustChangePassword': 'A folytatás előtt meg kell változtatnod a jelszavad. Kérjük, adj meg egy új jelszót alább.', 'admin.notifications.title': 'Értesítések', 'admin.notifications.hint': 'Válasszon értesítési csatornát. Egyszerre csak egy lehet aktív.', 'admin.notifications.none': 'Kikapcsolva', @@ -746,6 +747,7 @@ const hu: Record = { 'trip.toast.reservationAdded': 'Foglalás hozzáadva', 'trip.toast.deleted': 'Törölve', 'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?', + 'trip.loadingPhotos': 'Helyek fotóinak betöltése...', // Napi terv oldalsáv 'dayplan.emptyDay': 'Nincs tervezett hely erre a napra', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 46fe748c..377a2dd4 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -246,6 +246,7 @@ const it: Record = { 'settings.mfa.toastEnabled': 'Autenticazione a due fattori abilitata', 'settings.mfa.toastDisabled': 'Autenticazione a due fattori disabilitata', 'settings.mfa.demoBlocked': 'Non disponibile in modalità demo', + 'settings.mustChangePassword': 'Devi cambiare la password prima di continuare. Imposta una nuova password qui sotto.', 'admin.notifications.title': 'Notifiche', 'admin.notifications.hint': 'Scegli un canale di notifica. Solo uno può essere attivo alla volta.', 'admin.notifications.none': 'Disattivato', @@ -691,7 +692,6 @@ const it: Record = { 'atlas.statsTab': 'Statistiche', 'atlas.bucketTab': 'Lista desideri', 'atlas.addBucket': 'Aggiungi alla lista desideri', - 'atlas.bucketNamePlaceholder': 'Luogo o destinazione...', 'atlas.bucketNotesPlaceholder': 'Note (opzionale)', 'atlas.bucketEmpty': 'La tua lista desideri è vuota', 'atlas.bucketEmptyHint': 'Aggiungi luoghi che sogni di visitare', @@ -724,6 +724,7 @@ const it: Record = { 'atlas.tripPlural': 'Viaggi', 'atlas.placeVisited': 'Luogo visitato', 'atlas.placesVisited': 'Luoghi visitati', + 'atlas.searchCountry': 'Cerca un paese...', // Trip Planner 'trip.tabs.plan': 'Programma', @@ -746,6 +747,7 @@ const it: Record = { 'trip.toast.reservationAdded': 'Prenotazione aggiunta', 'trip.toast.deleted': 'Eliminato', 'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?', + 'trip.loadingPhotos': 'Caricamento foto dei luoghi...', // Day Plan Sidebar 'dayplan.emptyDay': 'Nessun luogo programmato per questo giorno', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index c99fee71..eb22e5b8 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -243,6 +243,7 @@ const nl: Record = { 'settings.roleAdmin': 'Beheerder', 'settings.oidcLinked': 'Gekoppeld met', 'settings.changePassword': 'Wachtwoord wijzigen', + 'settings.mustChangePassword': 'U moet uw wachtwoord wijzigen voordat u kunt doorgaan. Stel hieronder een nieuw wachtwoord in.', 'settings.currentPassword': 'Huidig wachtwoord', 'settings.currentPasswordRequired': 'Huidig wachtwoord is verplicht', 'settings.newPassword': 'Nieuw wachtwoord', @@ -720,9 +721,7 @@ const nl: Record = { 'atlas.addToBucket': 'Aan bucket list toevoegen', 'atlas.addPoi': 'Plaats toevoegen', 'atlas.searchCountry': 'Zoek een land...', - 'atlas.bucketNamePlaceholder': 'Naam (land, stad, plek…)', 'atlas.month': 'Maand', - 'atlas.year': 'Jaar', 'atlas.addToBucketHint': 'Opslaan als plek die je wilt bezoeken', 'atlas.bucketWhen': 'Wanneer ben je van plan te gaan?', @@ -735,6 +734,7 @@ const nl: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Bestanden', 'trip.loading': 'Reis laden...', + 'trip.loadingPhotos': 'Plaatsfoto laden...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Plaatsen', 'trip.toast.placeUpdated': 'Plaats bijgewerkt', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 2ea67fa5..03c843ae 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -243,6 +243,7 @@ const ru: Record = { 'settings.roleAdmin': 'Администратор', 'settings.oidcLinked': 'Связан с', 'settings.changePassword': 'Изменить пароль', + 'settings.mustChangePassword': 'Вы должны сменить пароль перед продолжением. Пожалуйста, установите новый пароль ниже.', 'settings.currentPassword': 'Текущий пароль', 'settings.currentPasswordRequired': 'Текущий пароль обязателен', 'settings.newPassword': 'Новый пароль', @@ -720,9 +721,7 @@ const ru: Record = { 'atlas.addToBucket': 'В список желаний', 'atlas.addPoi': 'Добавить место', 'atlas.searchCountry': 'Поиск страны...', - 'atlas.bucketNamePlaceholder': 'Название (страна, город, место…)', 'atlas.month': 'Месяц', - 'atlas.year': 'Год', 'atlas.addToBucketHint': 'Сохранить как место для посещения', 'atlas.bucketWhen': 'Когда вы планируете поехать?', @@ -735,6 +734,7 @@ const ru: Record = { 'trip.tabs.budget': 'Бюджет', 'trip.tabs.files': 'Файлы', 'trip.loading': 'Загрузка поездки...', + 'trip.loadingPhotos': 'Загрузка фото мест...', 'trip.mobilePlan': 'План', 'trip.mobilePlaces': 'Места', 'trip.toast.placeUpdated': 'Место обновлено', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 5fc6a1fb..156dec53 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -243,6 +243,7 @@ const zh: Record = { 'settings.roleAdmin': '管理员', 'settings.oidcLinked': '已关联', 'settings.changePassword': '修改密码', + 'settings.mustChangePassword': '您必须更改密码才能继续。请在下方设置新密码。', 'settings.currentPassword': '当前密码', 'settings.currentPasswordRequired': '请输入当前密码', 'settings.newPassword': '新密码', @@ -720,9 +721,7 @@ const zh: Record = { 'atlas.addToBucket': '添加到心愿单', 'atlas.addPoi': '添加地点', 'atlas.searchCountry': '搜索国家...', - 'atlas.bucketNamePlaceholder': '名称(国家、城市、地点…)', 'atlas.month': '月份', - 'atlas.year': '年份', 'atlas.addToBucketHint': '保存为想去的地方', 'atlas.bucketWhen': '你计划什么时候去?', @@ -735,6 +734,7 @@ const zh: Record = { 'trip.tabs.budget': '预算', 'trip.tabs.files': '文件', 'trip.loading': '加载旅行中...', + 'trip.loadingPhotos': '正在加载地点照片...', 'trip.mobilePlan': '计划', 'trip.mobilePlaces': '地点', 'trip.toast.placeUpdated': '地点已更新', diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 5bc90550..3b507446 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react' +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import ReactDOM from 'react-dom' import { useParams, useNavigate } from 'react-router-dom' import { useTripStore } from '../store/tripStore' import { useCanDo } from '../store/permissionsStore' import { useSettingsStore } from '../store/settingsStore' import { MapView } from '../components/Map/MapView' +import { getCached, fetchPhoto } from '../services/photoService' import DayPlanSidebar from '../components/Planner/DayPlanSidebar' import PlacesSidebar from '../components/Planner/PlacesSidebar' import PlaceInspector from '../components/Planner/PlaceInspector' @@ -23,7 +24,7 @@ import Navbar from '../components/Layout/Navbar' import { useToast } from '../components/shared/Toast' import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react' import { useTranslation } from '../i18n' -import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client' +import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client' import ConfirmDialog from '../components/shared/ConfirmDialog' import { useResizablePanels } from '../hooks/useResizablePanels' import { useTripWebSocket } from '../hooks/useTripWebSocket' @@ -37,8 +38,19 @@ export default function TripPlannerPage(): React.ReactElement | null { const toast = useToast() const { t, language } = useTranslation() const { settings } = useSettingsStore() - const tripStore = useTripStore() - const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore + const trip = useTripStore(s => s.trip) + const days = useTripStore(s => s.days) + const places = useTripStore(s => s.places) + const assignments = useTripStore(s => s.assignments) + const packingItems = useTripStore(s => s.packingItems) + const categories = useTripStore(s => s.categories) + const reservations = useTripStore(s => s.reservations) + const budgetItems = useTripStore(s => s.budgetItems) + const files = useTripStore(s => s.files) + const selectedDayId = useTripStore(s => s.selectedDayId) + const isLoading = useTripStore(s => s.isLoading) + // Actions — stable references, don't cause re-renders + const tripActions = useRef(useTripStore.getState()).current const can = useCanDo() const canUploadFiles = can('file_upload', trip) @@ -50,7 +62,7 @@ export default function TripPlannerPage(): React.ReactElement | null { const loadAccommodations = useCallback(() => { if (tripId) { accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) - tripStore.loadReservations(tripId) + tripActions.loadReservations(tripId) } }, [tripId]) @@ -83,8 +95,8 @@ export default function TripPlannerPage(): React.ReactElement | null { const handleTabChange = (tabId: string): void => { setActiveTab(tabId) sessionStorage.setItem(`trip-tab-${tripId}`, tabId) - if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId) - if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId) + if (tabId === 'finanzplan') tripActions.loadBudgetItems?.(tripId) + if (tabId === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId) } const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels() const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection() @@ -109,11 +121,25 @@ export default function TripPlannerPage(): React.ReactElement | null { return () => mq.removeEventListener('change', handler) }, []) + // Start photo fetches during splash screen so images are ready when map mounts + useEffect(() => { + if (isLoading || !places || places.length === 0) return + for (const p of places) { + if (p.image_url) continue + const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}` + if (!cacheKey || getCached(cacheKey)) continue + const photoId = p.google_place_id || p.osm_id + if (photoId || (p.lat && p.lng)) { + fetchPhoto(cacheKey, photoId || `coords:${p.lat}:${p.lng}`, p.lat, p.lng, p.name) + } + } + }, [isLoading, places]) + // Load trip + files (needed for place inspector file section) useEffect(() => { if (tripId) { - tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') }) - tripStore.loadFiles(tripId) + tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') }) + tripActions.loadFiles(tripId) loadAccommodations() tripsApi.getMembers(tripId).then(d => { // Combine owner + members into one list @@ -124,7 +150,7 @@ export default function TripPlannerPage(): React.ReactElement | null { }, [tripId]) useEffect(() => { - if (tripId) tripStore.loadReservations(tripId) + if (tripId) tripActions.loadReservations(tripId) }, [tripId]) useTripWebSocket(tripId) @@ -139,15 +165,15 @@ export default function TripPlannerPage(): React.ReactElement | null { }) }, [places, mapCategoryFilter]) - const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation(tripStore, selectedDayId) + const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId) const handleSelectDay = useCallback((dayId, skipFit) => { const changed = dayId !== selectedDayId - tripStore.setSelectedDay(dayId) + tripActions.setSelectedDay(dayId) if (changed && !skipFit) setFitKey(k => k + 1) setMobileSidebarOpen(null) updateRouteForDay(dayId) - }, [tripStore, updateRouteForDay, selectedDayId]) + }, [updateRouteForDay, selectedDayId]) const handlePlaceClick = useCallback((placeId, assignmentId) => { if (assignmentId) { @@ -191,11 +217,11 @@ export default function TripPlannerPage(): React.ReactElement | null { if (editingPlace) { // Always strip time fields from place update — time is per-assignment only const { place_time, end_time, ...placeData } = data - await tripStore.updatePlace(tripId, editingPlace.id, placeData) + await tripActions.updatePlace(tripId, editingPlace.id, placeData) // If editing from assignment context, save time per-assignment if (editingAssignmentId) { await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null }) - await tripStore.refreshDays(tripId) + await tripActions.refreshDays(tripId) } // Upload pending files with place_id if (pendingFiles?.length > 0) { @@ -203,23 +229,23 @@ export default function TripPlannerPage(): React.ReactElement | null { const fd = new FormData() fd.append('file', file) fd.append('place_id', editingPlace.id) - try { await tripStore.addFile(tripId, fd) } catch {} + try { await tripActions.addFile(tripId, fd) } catch {} } } toast.success(t('trip.toast.placeUpdated')) } else { - const place = await tripStore.addPlace(tripId, data) + const place = await tripActions.addPlace(tripId, data) if (pendingFiles?.length > 0 && place?.id) { for (const file of pendingFiles) { const fd = new FormData() fd.append('file', file) fd.append('place_id', place.id) - try { await tripStore.addFile(tripId, fd) } catch {} + try { await tripActions.addFile(tripId, fd) } catch {} } } toast.success(t('trip.toast.placeAdded')) } - }, [editingPlace, editingAssignmentId, tripId, tripStore, toast]) + }, [editingPlace, editingAssignmentId, tripId, toast]) const handleDeletePlace = useCallback((placeId) => { setDeletePlaceId(placeId) @@ -228,34 +254,34 @@ export default function TripPlannerPage(): React.ReactElement | null { const confirmDeletePlace = useCallback(async () => { if (!deletePlaceId) return try { - await tripStore.deletePlace(tripId, deletePlaceId) + await tripActions.deletePlace(tripId, deletePlaceId) if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null) toast.success(t('trip.toast.placeDeleted')) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } - }, [deletePlaceId, tripId, tripStore, toast, selectedPlaceId]) + }, [deletePlaceId, tripId, toast, selectedPlaceId]) const handleAssignToDay = useCallback(async (placeId, dayId, position) => { const target = dayId || selectedDayId if (!target) { toast.error(t('trip.toast.selectDay')); return } try { - await tripStore.assignPlaceToDay(tripId, target, placeId, position) + await tripActions.assignPlaceToDay(tripId, target, placeId, position) toast.success(t('trip.toast.assignedToDay')) updateRouteForDay(target) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } - }, [selectedDayId, tripId, tripStore, toast, updateRouteForDay]) + }, [selectedDayId, tripId, toast, updateRouteForDay]) const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => { try { - await tripStore.removeAssignment(tripId, dayId, assignmentId) + await tripActions.removeAssignment(tripId, dayId, assignmentId) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } - }, [tripId, tripStore, toast, updateRouteForDay]) + }, [tripId, toast, updateRouteForDay]) const handleReorder = useCallback((dayId, orderedIds) => { try { - tripStore.reorderAssignments(tripId, dayId, orderedIds).catch(() => {}) + tripActions.reorderAssignments(tripId, dayId, orderedIds).catch(() => {}) // Update route immediately from orderedIds - const dayItems = tripStore.assignments[String(dayId)] || [] + const dayItems = useTripStore.getState().assignments[String(dayId)] || [] const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean) const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng) if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng])) @@ -263,17 +289,17 @@ export default function TripPlannerPage(): React.ReactElement | null { setRouteInfo(null) } catch { toast.error(t('trip.toast.reorderError')) } - }, [tripId, tripStore, toast]) + }, [tripId, toast]) const handleUpdateDayTitle = useCallback(async (dayId, title) => { - try { await tripStore.updateDayTitle(tripId, dayId, title) } + try { await tripActions.updateDayTitle(tripId, dayId, title) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } - }, [tripId, tripStore, toast]) + }, [tripId, toast]) const handleSaveReservation = async (data) => { try { if (editingReservation) { - const r = await tripStore.updateReservation(tripId, editingReservation.id, data) + const r = await tripActions.updateReservation(tripId, editingReservation.id, data) toast.success(t('trip.toast.reservationUpdated')) setShowReservationModal(false) if (data.type === 'hotel') { @@ -281,7 +307,7 @@ export default function TripPlannerPage(): React.ReactElement | null { } return r } else { - const r = await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null }) + const r = await tripActions.addReservation(tripId, { ...data, day_id: selectedDayId || null }) toast.success(t('trip.toast.reservationAdded')) setShowReservationModal(false) // Refresh accommodations if hotel was created @@ -295,7 +321,7 @@ export default function TripPlannerPage(): React.ReactElement | null { const handleDeleteReservation = async (id) => { try { - await tripStore.deleteReservation(tripId, id) + await tripActions.deleteReservation(tripId, id) toast.success(t('trip.toast.deleted')) // Refresh accommodations in case a hotel booking was deleted accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) @@ -332,12 +358,53 @@ export default function TripPlannerPage(): React.ReactElement | null { const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" } - if (isLoading) { + // Splash screen — show for initial load + a brief moment for photos to start loading + const [splashDone, setSplashDone] = useState(false) + useEffect(() => { + if (!isLoading && trip) { + const timer = setTimeout(() => setSplashDone(true), 1500) + return () => clearTimeout(timer) + } + }, [isLoading, trip]) + + if (isLoading || !splashDone) { return ( -
-
-
- {t('trip.loading')} +
+ +
+ + + +
+
+ {trip?.title || 'TREK'} +
+
+ {t('trip.loadingPhotos')} +
+
+ {[0, 1, 2].map(i => ( +
+ ))}
) @@ -452,7 +519,7 @@ export default function TripPlannerPage(): React.ReactElement | null { onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} - onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }} + onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }} @@ -587,7 +654,7 @@ export default function TripPlannerPage(): React.ReactElement | null { onAssignToDay={handleAssignToDay} onRemoveAssignment={handleRemoveAssignment} files={files} - onFileUpload={canUploadFiles ? (fd) => tripStore.addFile(tripId, fd) : undefined} + onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} tripMembers={tripMembers} onSetParticipants={async (assignmentId, dayId, userIds) => { try { @@ -602,7 +669,7 @@ export default function TripPlannerPage(): React.ReactElement | null { })) } catch {} }} - onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }} + onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }} leftWidth={leftCollapsed ? 0 : leftWidth} rightWidth={rightCollapsed ? 0 : rightWidth} /> @@ -636,7 +703,7 @@ export default function TripPlannerPage(): React.ReactElement | null { onAssignToDay={handleAssignToDay} onRemoveAssignment={handleRemoveAssignment} files={files} - onFileUpload={canUploadFiles ? (fd) => tripStore.addFile(tripId, fd) : undefined} + onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} tripMembers={tripMembers} onSetParticipants={async (assignmentId, dayId, userIds) => { try { @@ -651,7 +718,7 @@ export default function TripPlannerPage(): React.ReactElement | null { })) } catch {} }} - onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }} + onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }} leftWidth={0} rightWidth={0} /> @@ -671,7 +738,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} /> + ? { 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) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} /> : { 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} /> }
@@ -714,9 +781,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
tripStore.addFile(tripId, fd)} - onDelete={(id) => tripStore.deleteFile(tripId, id)} - onUpdate={(id, data) => tripStore.loadFiles(tripId)} + onUpload={(fd) => tripActions.addFile(tripId, fd)} + onDelete={(id) => tripActions.deleteFile(tripId, id)} + onUpdate={(id, data) => tripActions.loadFiles(tripId)} places={places} days={days} assignments={assignments} @@ -740,10 +807,10 @@ export default function TripPlannerPage(): React.ReactElement | null { )}
- { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} /> - setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> + { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} /> + setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> - { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripStore.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} accommodations={tripAccommodations} /> + { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} /> setDeletePlaceId(null)} diff --git a/client/src/services/photoService.ts b/client/src/services/photoService.ts new file mode 100644 index 00000000..62fbb8ea --- /dev/null +++ b/client/src/services/photoService.ts @@ -0,0 +1,128 @@ +import { mapsApi } from '../api/client' + +// Shared photo cache — used by PlaceAvatar (sidebar) and MapView (map markers) +interface PhotoEntry { + photoUrl: string | null + thumbDataUrl: string | null +} + +const cache = new Map() +const inFlight = new Set() +const listeners = new Map void>>() +// Separate thumb listeners — called when thumbDataUrl becomes available after initial load +const thumbListeners = new Map void>>() + +function notify(key: string, entry: PhotoEntry) { + listeners.get(key)?.forEach(fn => fn(entry)) + listeners.delete(key) +} + +function notifyThumb(key: string, thumb: string) { + thumbListeners.get(key)?.forEach(fn => fn(thumb)) + thumbListeners.delete(key) +} + +export function onPhotoLoaded(key: string, fn: (entry: PhotoEntry) => void): () => void { + if (!listeners.has(key)) listeners.set(key, new Set()) + listeners.get(key)!.add(fn) + return () => { listeners.get(key)?.delete(fn) } +} + +// Subscribe to thumb availability — called when base64 thumb is ready (may be after photoUrl) +export function onThumbReady(key: string, fn: (thumb: string) => void): () => void { + if (!thumbListeners.has(key)) thumbListeners.set(key, new Set()) + thumbListeners.get(key)!.add(fn) + return () => { thumbListeners.get(key)?.delete(fn) } +} + +export function getCached(key: string): PhotoEntry | undefined { + return cache.get(key) +} + +export function isLoading(key: string): boolean { + return inFlight.has(key) +} + +// Convert image URL to base64 via canvas (CORS required — Wikimedia supports it) +function urlToBase64(url: string, size: number = 48): Promise { + return new Promise(resolve => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => { + try { + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const ctx = canvas.getContext('2d')! + const s = Math.min(img.naturalWidth, img.naturalHeight) + const sx = (img.naturalWidth - s) / 2 + const sy = (img.naturalHeight - s) / 2 + ctx.beginPath() + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2) + ctx.clip() + ctx.drawImage(img, sx, sy, s, s, 0, 0, size, size) + resolve(canvas.toDataURL('image/webp', 0.6)) + } catch { resolve(null) } + } + img.onerror = () => resolve(null) + img.src = url + }) +} + +export function fetchPhoto( + cacheKey: string, + photoId: string, + lat?: number, + lng?: number, + name?: string, + callback?: (entry: PhotoEntry) => void +) { + const cached = cache.get(cacheKey) + if (cached) { callback?.(cached); return } + + if (inFlight.has(cacheKey)) { + if (callback) onPhotoLoaded(cacheKey, callback) + return + } + + inFlight.add(cacheKey) + mapsApi.placePhoto(photoId, lat, lng, name) + .then(async (data: { photoUrl?: string }) => { + const photoUrl = data.photoUrl || null + if (!photoUrl) { + const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null } + cache.set(cacheKey, entry) + callback?.(entry) + notify(cacheKey, entry) + return + } + + // Store URL first — sidebar can show immediately + const entry: PhotoEntry = { photoUrl, thumbDataUrl: null } + cache.set(cacheKey, entry) + callback?.(entry) + notify(cacheKey, entry) + + // Generate base64 thumb in background + const thumb = await urlToBase64(photoUrl) + if (thumb) { + entry.thumbDataUrl = thumb + notifyThumb(cacheKey, thumb) + } + }) + .catch(() => { + const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null } + cache.set(cacheKey, entry) + callback?.(entry) + notify(cacheKey, entry) + }) + .finally(() => { inFlight.delete(cacheKey) }) +} + +export function getAllThumbs(): Record { + const r: Record = {} + for (const [k, v] of cache.entries()) { + if (v.thumbDataUrl) r[k] = v.thumbDataUrl + } + return r +} diff --git a/client/src/store/slices/placesSlice.ts b/client/src/store/slices/placesSlice.ts index 834d0bd3..01cab189 100644 --- a/client/src/store/slices/placesSlice.ts +++ b/client/src/store/slices/placesSlice.ts @@ -37,15 +37,22 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => updatePlace: async (tripId, placeId, placeData) => { try { const data = await placesApi.update(tripId, placeId, placeData) - set(state => ({ - places: state.places.map(p => p.id === placeId ? data.place : p), - assignments: Object.fromEntries( - Object.entries(state.assignments).map(([dayId, items]) => [ - dayId, - items.map((a: Assignment) => a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a) - ]) - ), - })) + set(state => { + const updatedAssignments = { ...state.assignments } + let changed = false + for (const [dayId, items] of Object.entries(state.assignments)) { + if (items.some((a: Assignment) => a.place?.id === placeId)) { + updatedAssignments[dayId] = items.map((a: Assignment) => + a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a + ) + changed = true + } + } + return { + places: state.places.map(p => p.id === placeId ? data.place : p), + ...(changed ? { assignments: updatedAssignments } : {}), + } + }) return data.place } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error updating place')) @@ -55,15 +62,20 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => deletePlace: async (tripId, placeId) => { try { await placesApi.delete(tripId, placeId) - set(state => ({ - places: state.places.filter(p => p.id !== placeId), - assignments: Object.fromEntries( - Object.entries(state.assignments).map(([dayId, items]) => [ - dayId, - items.filter((a: Assignment) => a.place?.id !== placeId) - ]) - ), - })) + set(state => { + const updatedAssignments = { ...state.assignments } + let changed = false + for (const [dayId, items] of Object.entries(state.assignments)) { + if (items.some((a: Assignment) => a.place?.id === placeId)) { + updatedAssignments[dayId] = items.filter((a: Assignment) => a.place?.id !== placeId) + changed = true + } + } + return { + places: state.places.filter(p => p.id !== placeId), + ...(changed ? { assignments: updatedAssignments } : {}), + } + }) } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error deleting place')) } diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index ea707f1e..46ced7d9 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -154,7 +154,7 @@ async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Pro ggslimit: '5', prop: 'imageinfo', iiprop: 'url|extmetadata|mime', - iiurlwidth: '600', + iiurlwidth: '400', }); try { const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } }); @@ -380,11 +380,14 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp const { placeId } = req.params; const cached = photoCache.get(placeId); - if (cached && Date.now() - cached.fetchedAt < PHOTO_TTL) { - if (cached.error) { - return res.status(404).json({ error: `(Cache) No photo available` }); + const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors + if (cached) { + const ttl = cached.error ? ERROR_TTL : PHOTO_TTL; + if (Date.now() - cached.fetchedAt < ttl) { + if (cached.error) return res.status(404).json({ error: `(Cache) No photo available` }); + return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution }); } - return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution }); + photoCache.delete(placeId); } // Wikimedia Commons fallback for OSM places (using lat/lng query params) @@ -436,7 +439,7 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp const attribution = photo.authorAttributions?.[0]?.displayName || null; const mediaRes = await fetch( - `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&skipHttpRedirect=true`, + `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`, { headers: { 'X-Goog-Api-Key': apiKey } } ); const mediaData = await mediaRes.json() as { photoUri?: string };