diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 26e744a..58f2d11 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useMemo, useCallback } from 'react' +import { useEffect, useRef, useState, useMemo, useCallback, memo } from 'react' import DOM from 'react-dom' import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet' import MarkerClusterGroup from 'react-leaflet-cluster' @@ -65,7 +65,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) { cursor:pointer;flex-shrink:0;position:relative; ">
- +
${badgeHtml} `, @@ -330,7 +330,7 @@ function LocationTracker() { ) } -export function MapView({ +export const MapView = memo(function MapView({ places = [], dayPlaces = [], route = null, @@ -404,6 +404,63 @@ export function MapView({ fetchNext() }, [places]) + const clusterIconCreateFunction = useCallback((cluster) => { + const count = cluster.getChildCount() + const size = count < 10 ? 36 : count < 50 ? 42 : 48 + return L.divIcon({ + html: `
${count}
`, + className: 'marker-cluster-wrapper', + iconSize: L.point(size, size), + }) + }, []) + + 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 orderNumbers = dayOrderMap[place.id] ?? null + const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected) + + return ( + onMarkerClick && onMarkerClick(place.id), + }} + zIndexOffset={isSelected ? 1000 : 0} + > + +
+
+ {place.name} +
+ {place.category_name && (() => { + const CatIcon = getCategoryIcon(place.category_icon) + return ( +
+ + {place.category_name} +
+ ) + })()} + {place.address && ( +
+ {place.address} +
+ )} +
+
+
+ ) + }), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick]) + return ( @@ -433,65 +491,9 @@ export function MapView({ showCoverageOnHover={false} zoomToBoundsOnClick singleMarkerMode - iconCreateFunction={(cluster) => { - const count = cluster.getChildCount() - const size = count < 10 ? 36 : count < 50 ? 42 : 48 - return L.divIcon({ - html: `
- ${count} -
`, - className: 'marker-cluster-wrapper', - iconSize: L.point(size, size), - }) - }} + iconCreateFunction={clusterIconCreateFunction} > - {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 orderNumbers = dayOrderMap[place.id] ?? null - const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected) - - return ( - onMarkerClick && onMarkerClick(place.id), - }} - zIndexOffset={isSelected ? 1000 : 0} - > - -
-
- {place.name} -
- {place.category_name && (() => { - const CatIcon = getCategoryIcon(place.category_icon) - return ( -
- - {place.category_name} -
- ) - })()} - {place.address && ( -
- {place.address} -
- )} -
-
-
- ) - })} + {markers} {route && route.length > 1 && ( @@ -510,4 +512,4 @@ export function MapView({ )}
) -} +}) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 9bc511a..3aee253 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -2,7 +2,7 @@ interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string } declare global { interface Window { __dragData: DragDataPayload | null } } -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect, useRef, useMemo } from 'react' import ReactDOM from 'react-dom' import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react' @@ -78,7 +78,7 @@ interface DayPlanSidebarProps { onNavigateToFiles?: () => void } -export default function DayPlanSidebar({ +const DayPlanSidebar = React.memo(function DayPlanSidebar({ tripId, trip, days, places, categories, assignments, selectedDayId, selectedPlaceId, selectedAssignmentId, @@ -323,6 +323,16 @@ export default function DayPlanSidebar({ return result.sort((a, b) => a.sortKey - b.sortKey) } + // Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover) + // eslint-disable-next-line react-hooks/exhaustive-deps + const mergedItemsMap = useMemo(() => { + const map: Record> = {} + days.forEach(day => { map[day.id] = getMergedItems(day.id) }) + return map + // getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [days, assignments, dayNotes, reservations]) + const openAddNote = (dayId, e) => { e?.stopPropagation() _openAddNote(dayId, getMergedItems, (id) => { @@ -669,10 +679,10 @@ export default function DayPlanSidebar({ setDraggingId(null) } - const totalCost = days.reduce((s, d) => { + const totalCost = useMemo(() => days.reduce((s, d) => { const da = assignments[String(d.id)] || [] return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0) - }, 0) + }, 0), [days, assignments]) // Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng) @@ -756,12 +766,12 @@ export default function DayPlanSidebar({ const formattedDate = formatDate(day.date, locale) const loc = da.find(a => a.place?.lat && a.place?.lng) const isDragTarget = dragOverDayId === day.id - const merged = getMergedItems(day.id) + const merged = mergedItemsMap[day.id] || [] const dayNoteUi = noteUi[day.id] const placeItems = merged.filter(i => i.type === 'place') return ( -
+
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
{ onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }} @@ -1663,4 +1673,6 @@ export default function DayPlanSidebar({
) -} +}) + +export default DayPlanSidebar diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index d49c58e..94d742b 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -30,7 +30,7 @@ interface PlacesSidebarProps { onCategoryFilterChange?: (categoryId: string) => void } -export default function PlacesSidebar({ +const PlacesSidebar = React.memo(function PlacesSidebar({ tripId, places, categories, assignments, selectedDayId, selectedPlaceId, onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, }: PlacesSidebarProps) { @@ -69,9 +69,9 @@ export default function PlacesSidebar({ const [catDropOpen, setCatDropOpen] = useState(false) // Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen) - const plannedIds = new Set( + const plannedIds = useMemo(() => new Set( Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean)) - ) + ), [assignments]) const filtered = useMemo(() => places.filter(p => { if (filter === 'unplanned' && plannedIds.has(p.id)) return false @@ -79,7 +79,7 @@ export default function PlacesSidebar({ if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && !(p.address || '').toLowerCase().includes(search.toLowerCase())) return false return true - }), [places, filter, categoryFilters, search, plannedIds.size]) + }), [places, filter, categoryFilters, search, plannedIds]) const isAssignedToSelectedDay = (placeId) => selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId) @@ -363,4 +363,6 @@ export default function PlacesSidebar({
) -} +}) + +export default PlacesSidebar diff --git a/client/src/components/shared/PlaceAvatar.tsx b/client/src/components/shared/PlaceAvatar.tsx index 027e411..ba682cf 100644 --- a/client/src/components/shared/PlaceAvatar.tsx +++ b/client/src/components/shared/PlaceAvatar.tsx @@ -86,6 +86,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P src={photoSrc} alt={place.name} loading="lazy" + decoding="async" style={{ width: '100%', height: '100%', objectFit: 'cover' }} onError={() => setPhotoSrc(null)} /> diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index 76b81bc..d2163df 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -122,18 +122,19 @@ async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Pro action: 'query', format: 'json', titles: name, prop: 'pageimages', - piprop: 'original', + piprop: 'thumbnail', + pithumbsize: '400', pilimit: '1', redirects: '1', }); const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } }); if (res.ok) { - const data = await res.json() as { query?: { pages?: Record } }; + const data = await res.json() as { query?: { pages?: Record } }; const pages = data.query?.pages; if (pages) { for (const page of Object.values(pages)) { - if (page.original?.source) { - return { photoUrl: page.original.source, attribution: 'Wikipedia' }; + if (page.thumbnail?.source) { + return { photoUrl: page.thumbnail.source, attribution: 'Wikipedia' }; } } } @@ -202,7 +203,7 @@ function getMapsKey(userId: number): string | null { return admin?.maps_api_key || null; } -const photoCache = new Map(); +const photoCache = new Map(); const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours const CACHE_MAX_ENTRIES = 1000; const CACHE_PRUNE_TARGET = 500; @@ -378,6 +379,9 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp 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` }); + } return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution }); } @@ -396,10 +400,12 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp if (wiki) { photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() }); return res.json(wiki); + } else { + photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); } } catch { /* fall through */ } } - return res.status(404).json({ error: 'No photo available' }); + return res.status(404).json({ error: '(Wikimedia) No photo available' }); } // Google Photos @@ -414,11 +420,13 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp if (!detailsRes.ok) { console.error('Google Places photo details error:', details.error?.message || detailsRes.status); - return res.status(404).json({ error: 'Photo could not be retrieved' }); + photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); + return res.status(404).json({ error: '(Google Places) Photo could not be retrieved' }); } if (!details.photos?.length) { - return res.status(404).json({ error: 'No photo available' }); + photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); + return res.status(404).json({ error: '(Google Places) No photo available' }); } const photo = details.photos[0]; @@ -432,7 +440,8 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp const photoUrl = mediaData.photoUri; if (!photoUrl) { - return res.status(404).json({ error: 'Photo URL not available' }); + photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true }); + return res.status(404).json({ error: '(Google Places) Photo URL not available' }); } photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() }); @@ -448,6 +457,7 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp res.json({ photoUrl, attribution }); } catch (err: unknown) { console.error('Place photo error:', err); + photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); res.status(500).json({ error: 'Error fetching photo' }); } });