Performance on trip planner (Maybe ?)
This commit is contained in:
@@ -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 DOM from 'react-dom'
|
||||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||||
@@ -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)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" />
|
<img src="${escAttr(place.image_url)}" loading="lazy" decoding="async" style="width:100%;height:100%;object-fit:cover;" />
|
||||||
</div>
|
</div>
|
||||||
${badgeHtml}
|
${badgeHtml}
|
||||||
</div>`,
|
</div>`,
|
||||||
@@ -330,7 +330,7 @@ function LocationTracker() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapView({
|
export const MapView = memo(function MapView({
|
||||||
places = [],
|
places = [],
|
||||||
dayPlaces = [],
|
dayPlaces = [],
|
||||||
route = null,
|
route = null,
|
||||||
@@ -404,6 +404,63 @@ export function MapView({
|
|||||||
fetchNext()
|
fetchNext()
|
||||||
}, [places])
|
}, [places])
|
||||||
|
|
||||||
|
const clusterIconCreateFunction = useCallback((cluster) => {
|
||||||
|
const count = cluster.getChildCount()
|
||||||
|
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||||
|
return L.divIcon({
|
||||||
|
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
|
||||||
|
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 (
|
||||||
|
<Marker
|
||||||
|
key={place.id}
|
||||||
|
position={[place.lat, place.lng]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||||
|
}}
|
||||||
|
zIndexOffset={isSelected ? 1000 : 0}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
direction="right"
|
||||||
|
offset={[0, 0]}
|
||||||
|
opacity={1}
|
||||||
|
className="map-tooltip"
|
||||||
|
>
|
||||||
|
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||||
|
{place.name}
|
||||||
|
</div>
|
||||||
|
{place.category_name && (() => {
|
||||||
|
const CatIcon = getCategoryIcon(place.category_icon)
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||||
|
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{place.address && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{place.address}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
)
|
||||||
|
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={center}
|
center={center}
|
||||||
@@ -416,6 +473,7 @@ export function MapView({
|
|||||||
url={tileUrl}
|
url={tileUrl}
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
maxZoom={19}
|
maxZoom={19}
|
||||||
|
keepBuffer={4}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MapController center={center} zoom={zoom} />
|
<MapController center={center} zoom={zoom} />
|
||||||
@@ -433,65 +491,9 @@ export function MapView({
|
|||||||
showCoverageOnHover={false}
|
showCoverageOnHover={false}
|
||||||
zoomToBoundsOnClick
|
zoomToBoundsOnClick
|
||||||
singleMarkerMode
|
singleMarkerMode
|
||||||
iconCreateFunction={(cluster) => {
|
iconCreateFunction={clusterIconCreateFunction}
|
||||||
const count = cluster.getChildCount()
|
|
||||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
|
||||||
return L.divIcon({
|
|
||||||
html: `<div class="marker-cluster-custom"
|
|
||||||
style="width:${size}px;height:${size}px;">
|
|
||||||
<span>${count}</span>
|
|
||||||
</div>`,
|
|
||||||
className: 'marker-cluster-wrapper',
|
|
||||||
iconSize: L.point(size, size),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{places.map((place) => {
|
{markers}
|
||||||
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 (
|
|
||||||
<Marker
|
|
||||||
key={place.id}
|
|
||||||
position={[place.lat, place.lng]}
|
|
||||||
icon={icon}
|
|
||||||
eventHandlers={{
|
|
||||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
|
||||||
}}
|
|
||||||
zIndexOffset={isSelected ? 1000 : 0}
|
|
||||||
>
|
|
||||||
<Tooltip
|
|
||||||
direction="right"
|
|
||||||
offset={[0, 0]}
|
|
||||||
opacity={1}
|
|
||||||
className="map-tooltip"
|
|
||||||
>
|
|
||||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
|
||||||
{place.name}
|
|
||||||
</div>
|
|
||||||
{place.category_name && (() => {
|
|
||||||
const CatIcon = getCategoryIcon(place.category_icon)
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
|
||||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{place.address && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
||||||
{place.address}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</Marker>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
|
|
||||||
{route && route.length > 1 && (
|
{route && route.length > 1 && (
|
||||||
@@ -510,4 +512,4 @@ export function MapView({
|
|||||||
)}
|
)}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
|
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
|
||||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
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 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'
|
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
|
onNavigateToFiles?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DayPlanSidebar({
|
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||||
tripId,
|
tripId,
|
||||||
trip, days, places, categories, assignments,
|
trip, days, places, categories, assignments,
|
||||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||||
@@ -323,6 +323,16 @@ export default function DayPlanSidebar({
|
|||||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
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<number, ReturnType<typeof getMergedItems>> = {}
|
||||||
|
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) => {
|
const openAddNote = (dayId, e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
_openAddNote(dayId, getMergedItems, (id) => {
|
_openAddNote(dayId, getMergedItems, (id) => {
|
||||||
@@ -669,10 +679,10 @@ export default function DayPlanSidebar({
|
|||||||
setDraggingId(null)
|
setDraggingId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCost = days.reduce((s, d) => {
|
const totalCost = useMemo(() => days.reduce((s, d) => {
|
||||||
const da = assignments[String(d.id)] || []
|
const da = assignments[String(d.id)] || []
|
||||||
return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0)
|
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
|
// 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)
|
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 formattedDate = formatDate(day.date, locale)
|
||||||
const loc = da.find(a => a.place?.lat && a.place?.lng)
|
const loc = da.find(a => a.place?.lat && a.place?.lng)
|
||||||
const isDragTarget = dragOverDayId === day.id
|
const isDragTarget = dragOverDayId === day.id
|
||||||
const merged = getMergedItems(day.id)
|
const merged = mergedItemsMap[day.id] || []
|
||||||
const dayNoteUi = noteUi[day.id]
|
const dayNoteUi = noteUi[day.id]
|
||||||
const placeItems = merged.filter(i => i.type === 'place')
|
const placeItems = merged.filter(i => i.type === 'place')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)', contentVisibility: 'auto', containIntrinsicSize: '0 64px' }}>
|
||||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||||
<div
|
<div
|
||||||
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
||||||
@@ -1663,4 +1673,6 @@ export default function DayPlanSidebar({
|
|||||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export default DayPlanSidebar
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ interface PlacesSidebarProps {
|
|||||||
onCategoryFilterChange?: (categoryId: string) => void
|
onCategoryFilterChange?: (categoryId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlacesSidebar({
|
const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
|
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
|
||||||
}: PlacesSidebarProps) {
|
}: PlacesSidebarProps) {
|
||||||
@@ -69,9 +69,9 @@ export default function PlacesSidebar({
|
|||||||
const [catDropOpen, setCatDropOpen] = useState(false)
|
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 = useMemo(() => 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))
|
||||||
)
|
), [assignments])
|
||||||
|
|
||||||
const filtered = useMemo(() => 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
|
||||||
@@ -79,7 +79,7 @@ export default function PlacesSidebar({
|
|||||||
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])
|
}), [places, filter, categoryFilters, search, plannedIds])
|
||||||
|
|
||||||
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)
|
||||||
@@ -363,4 +363,6 @@ export default function PlacesSidebar({
|
|||||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export default PlacesSidebar
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
|||||||
src={photoSrc}
|
src={photoSrc}
|
||||||
alt={place.name}
|
alt={place.name}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
onError={() => setPhotoSrc(null)}
|
onError={() => setPhotoSrc(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -122,18 +122,19 @@ async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Pro
|
|||||||
action: 'query', format: 'json',
|
action: 'query', format: 'json',
|
||||||
titles: name,
|
titles: name,
|
||||||
prop: 'pageimages',
|
prop: 'pageimages',
|
||||||
piprop: 'original',
|
piprop: 'thumbnail',
|
||||||
|
pithumbsize: '400',
|
||||||
pilimit: '1',
|
pilimit: '1',
|
||||||
redirects: '1',
|
redirects: '1',
|
||||||
});
|
});
|
||||||
const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } });
|
const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json() as { query?: { pages?: Record<string, { original?: { source?: string } }> } };
|
const data = await res.json() as { query?: { pages?: Record<string, { thumbnail?: { source?: string } }> } };
|
||||||
const pages = data.query?.pages;
|
const pages = data.query?.pages;
|
||||||
if (pages) {
|
if (pages) {
|
||||||
for (const page of Object.values(pages)) {
|
for (const page of Object.values(pages)) {
|
||||||
if (page.original?.source) {
|
if (page.thumbnail?.source) {
|
||||||
return { photoUrl: page.original.source, attribution: 'Wikipedia' };
|
return { photoUrl: page.thumbnail.source, attribution: 'Wikipedia' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +203,7 @@ function getMapsKey(userId: number): string | null {
|
|||||||
return admin?.maps_api_key || null;
|
return admin?.maps_api_key || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number }>();
|
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number; error?: boolean }>();
|
||||||
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
||||||
const CACHE_MAX_ENTRIES = 1000;
|
const CACHE_MAX_ENTRIES = 1000;
|
||||||
const CACHE_PRUNE_TARGET = 500;
|
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);
|
const cached = photoCache.get(placeId);
|
||||||
if (cached && Date.now() - cached.fetchedAt < PHOTO_TTL) {
|
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 });
|
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) {
|
if (wiki) {
|
||||||
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
|
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
|
||||||
return res.json(wiki);
|
return res.json(wiki);
|
||||||
|
} else {
|
||||||
|
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||||
}
|
}
|
||||||
} catch { /* fall through */ }
|
} catch { /* fall through */ }
|
||||||
}
|
}
|
||||||
return res.status(404).json({ error: 'No photo available' });
|
return res.status(404).json({ error: '(Wikimedia) No photo available' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Google Photos
|
// Google Photos
|
||||||
@@ -414,11 +420,13 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
|||||||
|
|
||||||
if (!detailsRes.ok) {
|
if (!detailsRes.ok) {
|
||||||
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
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) {
|
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];
|
const photo = details.photos[0];
|
||||||
@@ -432,7 +440,8 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
|||||||
const photoUrl = mediaData.photoUri;
|
const photoUrl = mediaData.photoUri;
|
||||||
|
|
||||||
if (!photoUrl) {
|
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() });
|
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 });
|
res.json({ photoUrl, attribution });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Place photo error:', err);
|
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' });
|
res.status(500).json({ error: 'Error fetching photo' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user