diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx
index 85b4458..26e744a 100644
--- a/client/src/components/Map/MapView.tsx
+++ b/client/src/components/Map/MapView.tsx
@@ -65,7 +65,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
cursor:pointer;flex-shrink:0;position:relative;
">
-
})
+
${badgeHtml}
`,
@@ -360,33 +360,48 @@ export function MapView({
}, [leftWidth, rightWidth, hasInspector])
const [photoUrls, setPhotoUrls] = useState({})
- // Fetch photos for places (Google or Wikimedia Commons fallback)
+ // Fetch photos for places with concurrency limit to avoid blocking map rendering
useEffect(() => {
- places.forEach(place => {
- if (place.image_url) return
+ const queue = places.filter(place => {
+ if (place.image_url) return false
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
- if (!cacheKey) return
+ 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
+ return false
}
- if (mapPhotoInFlight.has(cacheKey)) return
+ if (mapPhotoInFlight.has(cacheKey)) return false
const photoId = place.google_place_id || place.osm_id
- if (!photoId && !(place.lat && place.lng)) return
- mapPhotoInFlight.add(cacheKey)
- 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)
- }
- mapPhotoInFlight.delete(cacheKey)
- })
- .catch(() => { mapPhotoCache.set(cacheKey, null); mapPhotoInFlight.delete(cacheKey) })
+ if (!photoId && !(place.lat && place.lng)) return false
+ return true
})
+
+ let active = 0
+ const MAX_CONCURRENT = 3
+ let idx = 0
+
+ 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}`
+ 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() })
+ }
+ }
+ fetchNext()
}, [places])
return (
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index 19ce046..32fdd2b 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -793,6 +793,18 @@ export default function DayPlanSidebar({
{(() => {
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
+ // Sort: check-out first, then ongoing stays, then check-in last
+ .sort((a, b) => {
+ const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
+ const bIsOut = b.end_day_id === day.id && b.start_day_id !== day.id
+ const aIsIn = a.start_day_id === day.id
+ const bIsIn = b.start_day_id === day.id
+ if (aIsOut && !bIsOut) return -1
+ if (!aIsOut && bIsOut) return 1
+ if (aIsIn && !bIsIn) return 1
+ if (!aIsIn && bIsIn) return -1
+ return 0
+ })
if (dayAccs.length === 0) return null
return dayAccs.map(acc => {
const isCheckIn = acc.start_day_id === day.id
diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx
index 3bef079..d49c58e 100644
--- a/client/src/components/Planner/PlacesSidebar.tsx
+++ b/client/src/components/Planner/PlacesSidebar.tsx
@@ -1,7 +1,8 @@
+import React from 'react'
import ReactDOM from 'react-dom'
-import { useState, useRef } from 'react'
+import { useState, useRef, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
-import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload } from 'lucide-react'
+import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
@@ -53,26 +54,32 @@ export default function PlacesSidebar({
}
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all')
- const [categoryFilter, setCategoryFilterLocal] = useState('')
+ const [categoryFilters, setCategoryFiltersLocal] = useState>(new Set())
- const setCategoryFilter = (val: string) => {
- setCategoryFilterLocal(val)
- onCategoryFilterChange?.(val)
+ const toggleCategoryFilter = (catId: string) => {
+ setCategoryFiltersLocal(prev => {
+ const next = new Set(prev)
+ if (next.has(catId)) next.delete(catId); else next.add(catId)
+ // Notify parent with first selected or empty
+ onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '')
+ return next
+ })
}
const [dayPickerPlace, setDayPickerPlace] = useState(null)
+ const [catDropOpen, setCatDropOpen] = useState(false)
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
const plannedIds = new Set(
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
)
- const filtered = places.filter(p => {
+ const filtered = useMemo(() => places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
- if (categoryFilter && String(p.category_id) !== String(categoryFilter)) return false
+ if (categoryFilters.size > 0 && !categoryFilters.has(String(p.category_id))) return false
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
return true
- })
+ }), [places, filter, categoryFilters, search, plannedIds.size])
const isAssignedToSelectedDay = (placeId) =>
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
@@ -139,21 +146,69 @@ export default function PlacesSidebar({
)}
- {/* Kategoriefilter */}
- {categories.length > 0 && (
-
- ({ value: String(c.id), label: c.name }))
- ]}
- />
-
- )}
+ {/* Category multi-select dropdown */}
+ {categories.length > 0 && (() => {
+ const label = categoryFilters.size === 0
+ ? t('places.allCategories')
+ : categoryFilters.size === 1
+ ? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')
+ : `${categoryFilters.size} ${t('places.categoriesSelected')}`
+ return (
+
+
+ {catDropOpen && (
+
+ {categories.map(c => {
+ const active = categoryFilters.has(String(c.id))
+ const CatIcon = getCategoryIcon(c.icon)
+ return (
+
+ )
+ })}
+ {categoryFilters.size > 0 && (
+
+ )}
+
+ )}
+
+ )
+ })()}
{/* Anzahl */}
@@ -211,6 +266,8 @@ export default function PlacesSidebar({
background: isSelected ? 'var(--border-faint)' : 'transparent',
borderBottom: '1px solid var(--border-faint)',
transition: 'background 0.1s',
+ contentVisibility: 'auto',
+ containIntrinsicSize: '0 52px',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
diff --git a/client/src/components/shared/PlaceAvatar.tsx b/client/src/components/shared/PlaceAvatar.tsx
index c250160..027e411 100644
--- a/client/src/components/shared/PlaceAvatar.tsx
+++ b/client/src/components/shared/PlaceAvatar.tsx
@@ -16,8 +16,18 @@ interface PlaceAvatarProps {
const photoCache = new Map()
const photoInFlight = new Set()
+// Event-based notification instead of polling intervals
+const photoListeners = new Map void>>()
-export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
+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)
useEffect(() => {
@@ -33,28 +43,27 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
}
if (photoInFlight.has(cacheKey)) {
- // Another instance is already fetching, wait for it
- const check = setInterval(() => {
- if (photoCache.has(cacheKey)) {
- clearInterval(check)
- const cached = photoCache.get(cacheKey)
- if (cached) setPhotoSrc(cached)
- }
- }, 200)
- return () => clearInterval(check)
+ // 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) }
}
+
photoInFlight.add(cacheKey)
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
.then((data: { photoUrl?: string }) => {
- if (data.photoUrl) {
- photoCache.set(cacheKey, data.photoUrl)
- setPhotoSrc(data.photoUrl)
- } else {
- photoCache.set(cacheKey, null)
- }
+ 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)
})
- .catch(() => { photoCache.set(cacheKey, null); photoInFlight.delete(cacheKey) })
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
const bgColor = category?.color || '#6366f1'
@@ -76,6 +85,7 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
setPhotoSrc(null)}
/>
@@ -88,4 +98,4 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
)
-}
+})
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index 1915460..06fbec0 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -647,6 +647,8 @@ const de: Record = {
'places.unplanned': 'Ungeplant',
'places.search': 'Orte suchen...',
'places.allCategories': 'Alle Kategorien',
+ 'places.categoriesSelected': 'Kategorien',
+ 'places.clearFilter': 'Filter zurücksetzen',
'places.count': '{count} Orte',
'places.countSingular': '1 Ort',
'places.allPlanned': 'Alle Orte sind eingeplant',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index 46e7a37..b34fe41 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -647,6 +647,8 @@ const en: Record = {
'places.unplanned': 'Unplanned',
'places.search': 'Search places...',
'places.allCategories': 'All Categories',
+ 'places.categoriesSelected': 'categories',
+ 'places.clearFilter': 'Clear filter',
'places.count': '{count} places',
'places.countSingular': '1 place',
'places.allPlanned': 'All places are planned',