feat: multi-select category filter, performance fixes, check-in/out order
- Category filter is now a multi-select dropdown with checkboxes - PlaceAvatar: replace 200ms polling intervals with event-based notification + React.memo for major performance improvement - Map photo fetches: concurrency limited to 3 + lazy loading on images - PlacesSidebar: content-visibility + useMemo for smooth scrolling - Accommodation labels: check-out now appears before check-in on same day - Timed places auto-sort chronologically when time is added
This commit is contained in:
@@ -65,7 +65,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
|||||||
cursor:pointer;flex-shrink:0;position:relative;
|
cursor:pointer;flex-shrink:0;position:relative;
|
||||||
">
|
">
|
||||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
||||||
<img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
|
<img src="${escAttr(place.image_url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" />
|
||||||
</div>
|
</div>
|
||||||
${badgeHtml}
|
${badgeHtml}
|
||||||
</div>`,
|
</div>`,
|
||||||
@@ -360,33 +360,48 @@ export function MapView({
|
|||||||
}, [leftWidth, rightWidth, hasInspector])
|
}, [leftWidth, rightWidth, hasInspector])
|
||||||
const [photoUrls, setPhotoUrls] = useState({})
|
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(() => {
|
useEffect(() => {
|
||||||
places.forEach(place => {
|
const queue = places.filter(place => {
|
||||||
if (place.image_url) return
|
if (place.image_url) return false
|
||||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||||
if (!cacheKey) return
|
if (!cacheKey) return false
|
||||||
if (mapPhotoCache.has(cacheKey)) {
|
if (mapPhotoCache.has(cacheKey)) {
|
||||||
const cached = mapPhotoCache.get(cacheKey)
|
const cached = mapPhotoCache.get(cacheKey)
|
||||||
if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
|
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
|
const photoId = place.google_place_id || place.osm_id
|
||||||
if (!photoId && !(place.lat && place.lng)) return
|
if (!photoId && !(place.lat && place.lng)) return false
|
||||||
mapPhotoInFlight.add(cacheKey)
|
return true
|
||||||
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) })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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])
|
}, [places])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -793,6 +793,18 @@ export default function DayPlanSidebar({
|
|||||||
</button>
|
</button>
|
||||||
{(() => {
|
{(() => {
|
||||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
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
|
if (dayAccs.length === 0) return null
|
||||||
return dayAccs.map(acc => {
|
return dayAccs.map(acc => {
|
||||||
const isCheckIn = acc.start_day_id === day.id
|
const isCheckIn = acc.start_day_id === day.id
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useState, useRef } from 'react'
|
import { useState, useRef, useMemo, useCallback } from 'react'
|
||||||
import DOM from 'react-dom'
|
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 PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@@ -53,26 +54,32 @@ export default function PlacesSidebar({
|
|||||||
}
|
}
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
const [categoryFilter, setCategoryFilterLocal] = useState('')
|
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const setCategoryFilter = (val: string) => {
|
const toggleCategoryFilter = (catId: string) => {
|
||||||
setCategoryFilterLocal(val)
|
setCategoryFiltersLocal(prev => {
|
||||||
onCategoryFilterChange?.(val)
|
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 [dayPickerPlace, setDayPickerPlace] = useState(null)
|
||||||
|
const [catDropOpen, setCatDropOpen] = useState(false)
|
||||||
|
|
||||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||||
const plannedIds = new Set(
|
const plannedIds = new Set(
|
||||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
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 (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()) &&
|
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||||
return true
|
return true
|
||||||
})
|
}), [places, filter, categoryFilters, search, plannedIds.size])
|
||||||
|
|
||||||
const isAssignedToSelectedDay = (placeId) =>
|
const isAssignedToSelectedDay = (placeId) =>
|
||||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||||
@@ -139,21 +146,69 @@ export default function PlacesSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Kategoriefilter */}
|
{/* Category multi-select dropdown */}
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (() => {
|
||||||
<div style={{ marginTop: 6 }}>
|
const label = categoryFilters.size === 0
|
||||||
<CustomSelect
|
? t('places.allCategories')
|
||||||
value={categoryFilter}
|
: categoryFilters.size === 1
|
||||||
onChange={setCategoryFilter}
|
? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')
|
||||||
placeholder={t('places.allCategories')}
|
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||||
size="sm"
|
return (
|
||||||
options={[
|
<div style={{ marginTop: 6, position: 'relative' }}>
|
||||||
{ value: '', label: t('places.allCategories') },
|
<button onClick={() => setCatDropOpen(v => !v)} style={{
|
||||||
...categories.map(c => ({ value: String(c.id), label: c.name }))
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
]}
|
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||||
/>
|
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
|
||||||
</div>
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
)}
|
}}>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||||
|
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||||
|
</button>
|
||||||
|
{catDropOpen && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, maxHeight: 200, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{categories.map(c => {
|
||||||
|
const active = categoryFilters.has(String(c.id))
|
||||||
|
const CatIcon = getCategoryIcon(c.icon)
|
||||||
|
return (
|
||||||
|
<button key={c.id} onClick={() => toggleCategoryFilter(String(c.id))} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||||
|
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||||
|
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||||
|
border: active ? 'none' : '1.5px solid var(--border-primary)',
|
||||||
|
background: active ? (c.color || 'var(--accent)') : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{active && <Check size={10} strokeWidth={3} color="white" />}
|
||||||
|
</div>
|
||||||
|
<CatIcon size={12} strokeWidth={2} color={c.color || 'var(--text-muted)'} />
|
||||||
|
<span style={{ flex: 1 }}>{c.name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{categoryFilters.size > 0 && (
|
||||||
|
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.('') }} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||||
|
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||||
|
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)',
|
||||||
|
marginTop: 2, borderTop: '1px solid var(--border-faint)',
|
||||||
|
}}>
|
||||||
|
<X size={10} /> {t('places.clearFilter')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Anzahl */}
|
{/* Anzahl */}
|
||||||
@@ -211,6 +266,8 @@ export default function PlacesSidebar({
|
|||||||
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
||||||
borderBottom: '1px solid var(--border-faint)',
|
borderBottom: '1px solid var(--border-faint)',
|
||||||
transition: 'background 0.1s',
|
transition: 'background 0.1s',
|
||||||
|
contentVisibility: 'auto',
|
||||||
|
containIntrinsicSize: '0 52px',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
||||||
|
|||||||
@@ -16,8 +16,18 @@ interface PlaceAvatarProps {
|
|||||||
|
|
||||||
const photoCache = new Map<string, string | null>()
|
const photoCache = new Map<string, string | null>()
|
||||||
const photoInFlight = new Set<string>()
|
const photoInFlight = new Set<string>()
|
||||||
|
// Event-based notification instead of polling intervals
|
||||||
|
const photoListeners = new Map<string, Set<(url: string | null) => 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<string | null>(place.image_url || null)
|
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,28 +43,27 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (photoInFlight.has(cacheKey)) {
|
if (photoInFlight.has(cacheKey)) {
|
||||||
// Another instance is already fetching, wait for it
|
// Subscribe to notification instead of polling
|
||||||
const check = setInterval(() => {
|
if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set())
|
||||||
if (photoCache.has(cacheKey)) {
|
const handler = (url: string | null) => { if (url) setPhotoSrc(url) }
|
||||||
clearInterval(check)
|
photoListeners.get(cacheKey)!.add(handler)
|
||||||
const cached = photoCache.get(cacheKey)
|
return () => { photoListeners.get(cacheKey)?.delete(handler) }
|
||||||
if (cached) setPhotoSrc(cached)
|
|
||||||
}
|
|
||||||
}, 200)
|
|
||||||
return () => clearInterval(check)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
photoInFlight.add(cacheKey)
|
photoInFlight.add(cacheKey)
|
||||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||||
.then((data: { photoUrl?: string }) => {
|
.then((data: { photoUrl?: string }) => {
|
||||||
if (data.photoUrl) {
|
const url = data.photoUrl || null
|
||||||
photoCache.set(cacheKey, data.photoUrl)
|
photoCache.set(cacheKey, url)
|
||||||
setPhotoSrc(data.photoUrl)
|
if (url) setPhotoSrc(url)
|
||||||
} else {
|
notifyListeners(cacheKey, url)
|
||||||
photoCache.set(cacheKey, null)
|
photoInFlight.delete(cacheKey)
|
||||||
}
|
})
|
||||||
|
.catch(() => {
|
||||||
|
photoCache.set(cacheKey, null)
|
||||||
|
notifyListeners(cacheKey, null)
|
||||||
photoInFlight.delete(cacheKey)
|
photoInFlight.delete(cacheKey)
|
||||||
})
|
})
|
||||||
.catch(() => { photoCache.set(cacheKey, null); photoInFlight.delete(cacheKey) })
|
|
||||||
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
|
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
|
||||||
|
|
||||||
const bgColor = category?.color || '#6366f1'
|
const bgColor = category?.color || '#6366f1'
|
||||||
@@ -76,6 +85,7 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
|
|||||||
<img
|
<img
|
||||||
src={photoSrc}
|
src={photoSrc}
|
||||||
alt={place.name}
|
alt={place.name}
|
||||||
|
loading="lazy"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
onError={() => setPhotoSrc(null)}
|
onError={() => setPhotoSrc(null)}
|
||||||
/>
|
/>
|
||||||
@@ -88,4 +98,4 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
|
|||||||
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -647,6 +647,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.unplanned': 'Ungeplant',
|
'places.unplanned': 'Ungeplant',
|
||||||
'places.search': 'Orte suchen...',
|
'places.search': 'Orte suchen...',
|
||||||
'places.allCategories': 'Alle Kategorien',
|
'places.allCategories': 'Alle Kategorien',
|
||||||
|
'places.categoriesSelected': 'Kategorien',
|
||||||
|
'places.clearFilter': 'Filter zurücksetzen',
|
||||||
'places.count': '{count} Orte',
|
'places.count': '{count} Orte',
|
||||||
'places.countSingular': '1 Ort',
|
'places.countSingular': '1 Ort',
|
||||||
'places.allPlanned': 'Alle Orte sind eingeplant',
|
'places.allPlanned': 'Alle Orte sind eingeplant',
|
||||||
|
|||||||
@@ -647,6 +647,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.unplanned': 'Unplanned',
|
'places.unplanned': 'Unplanned',
|
||||||
'places.search': 'Search places...',
|
'places.search': 'Search places...',
|
||||||
'places.allCategories': 'All Categories',
|
'places.allCategories': 'All Categories',
|
||||||
|
'places.categoriesSelected': 'categories',
|
||||||
|
'places.clearFilter': 'Clear filter',
|
||||||
'places.count': '{count} places',
|
'places.count': '{count} places',
|
||||||
'places.countSingular': '1 place',
|
'places.countSingular': '1 place',
|
||||||
'places.allPlanned': 'All places are planned',
|
'places.allPlanned': 'All places are planned',
|
||||||
|
|||||||
Reference in New Issue
Block a user