+
{/* 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' });
}
});