From 9563550e3c50ee9f288ee07d1dab6973fb0c0452 Mon Sep 17 00:00:00 2001 From: Alexandre Phiev Date: Sat, 8 Nov 2025 10:39:31 +0100 Subject: [PATCH 1/3] Refactor search and explore actions to use new SearchPlaceInView type; add lazy loading for place photos. Updated components to handle optional distance_km and improved photo handling in PlaceCard and SearchPlaceDetailView. Cleaned up unused code and comments for better readability. --- PHOTO_MIGRATION_SUMMARY.md | 76 +++++++ actions/explore.actions.ts | 6 +- actions/search.actions.ts | 98 ++++++++- .../result/details/PlacePhotoGallery.tsx | 35 ++- components/search/SearchPageComponent.tsx | 42 +++- components/search/result/Map.tsx | 14 +- components/search/result/PlaceCard.tsx | 81 +++++-- components/search/result/PlaceResultsGrid.tsx | 8 +- components/search/result/PopupContent.tsx | 4 +- components/search/result/SearchResults.tsx | 12 +- .../result/details/SearchPlaceDetailView.tsx | 77 ++++++- .../details/SearchPlacePhotoGallery.tsx | 205 ++++++++++++++++++ next.config.ts | 6 + types/search.types.ts | 56 +++-- types/supabase.ts | 144 +++++++++++- 15 files changed, 771 insertions(+), 93 deletions(-) create mode 100644 PHOTO_MIGRATION_SUMMARY.md create mode 100644 components/search/result/details/SearchPlacePhotoGallery.tsx diff --git a/PHOTO_MIGRATION_SUMMARY.md b/PHOTO_MIGRATION_SUMMARY.md new file mode 100644 index 0000000..e7eee8b --- /dev/null +++ b/PHOTO_MIGRATION_SUMMARY.md @@ -0,0 +1,76 @@ +# Photo Migration Summary + +## Overview +Successfully migrated the search page to use the new `place_photos` table for displaying photos from the database. + +## Changes Made + +### 1. Type Updates + +#### `/types/result.types.ts` +- Updated `PlacePhoto` interface to match the new `place_photos` table structure: + - Added `id: string` + - Changed `attribution?: string` to `attribution: string | null` + - Added `is_primary: boolean | null` + +#### `/actions/explore.actions.ts` +- Updated `PlacesInView` interface to use the new photo structure: + - Photos now include `id`, `url`, `attribution`, and `is_primary` fields + +### 2. Data Fetching Updates + +#### `/actions/explore.actions.ts` +- Added `fetchPhotosForPlaces()` function to efficiently fetch photos from `place_photos` table + - Only fetches necessary fields: `id`, `url`, `attribution`, `is_primary` + - Orders by `is_primary` (descending) then `created_at` (ascending) + - Groups photos by `place_id` for efficient lookup +- Updated `getPlacesInBounds()` to fetch and attach photos to places + +#### `/actions/search.actions.ts` +- Added `fetchPhotosForPlaces()` function (same implementation as explore) +- Updated `searchPlacesAction()` to fetch and attach photos to places + +### 3. Component Compatibility + +All search page components are already compatible with the new photo structure: + +#### `/components/search/result/PlaceCard.tsx` +- Already uses `photo.url` ✓ +- No changes needed + +#### `/components/search/result/details/SearchPlaceDetailView.tsx` +- Passes photos to `PlacePhotoGallery` component ✓ +- No changes needed + +#### `/components/discovery/result/details/PlacePhotoGallery.tsx` +- Already uses `photo.url` and `photo.attribution` ✓ +- Shared between search and discover pages +- No changes needed + +## Data Flow + +1. User performs search → `searchPlacesAction()` is called +2. RPC function `search_places_by_location` returns places from database +3. `fetchPhotosForPlaces()` queries `place_photos` table with place IDs +4. Photos are grouped by `place_id` and attached to corresponding places +5. Places with photos are returned to the UI +6. Components render photos using `photo.url` and optionally `photo.attribution` + +## Performance Considerations + +- Photos are fetched in a single query for all places (batch operation) +- Only necessary fields are selected from the database +- Photos are ordered by `is_primary` to show primary photos first +- Empty arrays are returned for places without photos (graceful degradation) + +## Testing Notes + +- No linter errors detected +- All TypeScript types are properly aligned +- Components handle missing photos gracefully +- Photo attribution is displayed when available + +## Not Changed + +The discover page continues to use Google Places API for photos, which is the correct behavior as it generates recommendations in real-time rather than querying the database. + diff --git a/actions/explore.actions.ts b/actions/explore.actions.ts index a03111b..0a40377 100644 --- a/actions/explore.actions.ts +++ b/actions/explore.actions.ts @@ -24,7 +24,7 @@ export interface GeoJSONGeometry { export interface PlacesInView { country: string description: string - distance_km: number + distance_km?: number // Optional because explore page doesn't always have distance id: string lat: number long: number @@ -36,10 +36,6 @@ export interface PlacesInView { website: string wikipedia_query: string metadata?: Json - photos?: { - url: string - caption: string - }[] } export async function getPlacesInBounds( diff --git a/actions/search.actions.ts b/actions/search.actions.ts index 6fbb4b2..6531065 100644 --- a/actions/search.actions.ts +++ b/actions/search.actions.ts @@ -1,8 +1,9 @@ 'use server' +import { SearchPlaceInView, SearchPlacePhoto } from '@/types/search.types' +import { Json } from '@/types/supabase' import { distanceToRadiusKm } from '@/utils/distance.utils' import { createClient } from '@/utils/supabase/server' -import { PlacesInView } from './explore.actions' interface SearchPlacesParams { latitude: number @@ -11,26 +12,99 @@ interface SearchPlacesParams { limit?: number } +/** + * Fetch photos for a single place from the place_photos table + * Used for lazy loading photos when place cards become visible + * @param placeId - The place ID to fetch photos for + * @param limit - Optional limit on number of photos (default: no limit) + */ +export async function getPlacePhotos( + placeId: string, + limit?: number +): Promise { + const supabase = await createClient() + + let query = supabase + .from('place_photos') + .select('id, place_id, url, attribution, is_primary') + .eq('place_id', placeId) + .order('is_primary', { ascending: false, nullsFirst: false }) + .order('created_at', { ascending: true }) + + if (limit) { + query = query.limit(limit) + } + + const { data, error } = await query + + if (error) { + console.error('Error fetching photos for place:', placeId, error) + return [] + } + + return ( + data?.map((photo) => ({ + id: photo.id, + url: photo.url, + attribution: photo.attribution, + is_primary: photo.is_primary, + })) || [] + ) +} + +/** + * Search places by location using RPC function + * Returns only the fields returned by the RPC function (no photos) + * Photos should be loaded lazily at the component level + */ export async function searchPlacesAction( params: SearchPlacesParams -): Promise { +): Promise { const supabase = await createClient() const { latitude, longitude, radiusKm, limit = 20 } = params - const { data, error } = await supabase.rpc('search_places_by_location', { - search_lat: latitude, - search_lng: longitude, - radius_km: radiusKm, - result_limit: limit, - min_score: 3, // Quite restrictive, but we want to be sure we're getting good results - }) + // Fetch places using RPC function - returns exactly what the RPC returns + const { data: placesData, error: placesError } = await supabase.rpc( + 'search_places_by_location', + { + search_lat: latitude, + search_lng: longitude, + radius_km: radiusKm, + result_limit: limit, + min_score: 3, // Quite restrictive, but we want to be sure we're getting good results + } + ) - if (error) { - console.error('Error searching places:', error) + if (placesError) { + console.error('Error searching places:', placesError) return [] } - return data + if (!placesData || placesData.length === 0) { + return [] + } + + // Map RPC return type to SearchPlaceInView (photos will be loaded lazily) + const places: SearchPlaceInView[] = placesData.map((place) => ({ + id: place.id, + country: place.country, + description: place.description, + distance_km: place.distance_km, + lat: place.lat, + long: place.long, + name: place.name, + region: place.region, + score: place.score, + source: place.source, + type: place.type, + website: place.website, + wikipedia_query: place.wikipedia_query, + metadata: place.metadata as Json | undefined, + // photos will be undefined initially, loaded lazily + photos: undefined, + })) + + return places } export async function searchPlaces( diff --git a/components/discovery/result/details/PlacePhotoGallery.tsx b/components/discovery/result/details/PlacePhotoGallery.tsx index 22b746e..6b8ab75 100644 --- a/components/discovery/result/details/PlacePhotoGallery.tsx +++ b/components/discovery/result/details/PlacePhotoGallery.tsx @@ -21,6 +21,35 @@ interface PlacePhotoGalleryProps { placeName: string } +/** + * Strip HTML tags from a string and return plain text + * Also decodes HTML entities like &, <, etc. + */ +function stripHtmlTags(html: string): string { + if (typeof document !== 'undefined') { + // Browser environment - use DOM parser for better accuracy + const tmp = document.createElement('div') + tmp.innerHTML = html + const text = tmp.textContent || tmp.innerText || '' + // Decode HTML entities + const decoded = document.createElement('textarea') + decoded.innerHTML = text + return decoded.value.trim() + } + // Server-side fallback - basic regex stripping and entity decoding + let cleaned = html.replace(/<[^>]*>/g, '') // Remove HTML tags + // Decode common HTML entities + cleaned = cleaned + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim() + return cleaned +} + export default function PlacePhotoGallery({ googleMapsUri, photos, @@ -199,7 +228,11 @@ export default function PlacePhotoGallery({ {validPhotos[selectedPhotoIndex].attribution && (
- Photo by {validPhotos[selectedPhotoIndex].attribution} + {validPhotos[selectedPhotoIndex].attribution.startsWith( + 'Photo by' + ) + ? stripHtmlTags(validPhotos[selectedPhotoIndex].attribution) + : `Photo by ${stripHtmlTags(validPhotos[selectedPhotoIndex].attribution)}`}
)} diff --git a/components/search/SearchPageComponent.tsx b/components/search/SearchPageComponent.tsx index 397dc8a..fcec19d 100644 --- a/components/search/SearchPageComponent.tsx +++ b/components/search/SearchPageComponent.tsx @@ -4,7 +4,6 @@ import { BoundingBox, getPlacesInBounds, ParkGeometry, - PlacesInView, } from '@/actions/explore.actions' import { searchPlacesAction } from '@/actions/search.actions' import { SearchFormModal } from '@/components/search/form/SearchFormModal' @@ -16,6 +15,7 @@ import { } from '@/components/ui/resizable' import { LocationInfo, reverseGeocode } from '@/lib/geocoding.service' import type { SearchFilters, SearchFormValues } from '@/types/search.types' +import { SearchPlaceInView } from '@/types/search.types' import { distanceToRadiusKm } from '@/utils/distance.utils' import { mapActivityToPlaceTypes } from '@/utils/place.utils' import { searchFormSchema } from '@/validation/search-form.validation' @@ -45,7 +45,7 @@ function SearchPageComponent({ parkGeometries }: SearchPageComponentProps) { ) const [isSearchOpen, setIsSearchOpen] = useState(false) - const [placeResults, setPlaceResults] = useState([]) + const [placeResults, setPlaceResults] = useState([]) const [isLoading, setIsLoading] = useState(true) const [currentFilters, setCurrentFilters] = useState( null @@ -57,9 +57,13 @@ function SearchPageComponent({ parkGeometries }: SearchPageComponentProps) { latitude: 46.603354, longitude: 1.888334, }) - const [selectedPlace, setSelectedPlace] = useState(null) + const [selectedPlace, setSelectedPlace] = useState( + null + ) const [activeCardIndex, setActiveCardIndex] = useState(-1) - const [hoveredPlace, setHoveredPlace] = useState(null) + const [hoveredPlace, setHoveredPlace] = useState( + null + ) const boundsChangeTimeoutRef = useRef(null) const isLoadingRef = useRef(false) @@ -290,7 +294,25 @@ function SearchPageComponent({ parkGeometries }: SearchPageComponentProps) { isLoadingRef.current = true setIsLoading(true) const placesInBounds = await getPlacesInBounds(bounds) - setPlaceResults(placesInBounds) + // Convert PlacesInView[] to SearchPlaceInView[] + const searchPlaces: SearchPlaceInView[] = placesInBounds.map((place) => ({ + id: place.id, + country: place.country, + description: place.description, + distance_km: place.distance_km ?? 0, // Default to 0 if undefined + lat: place.lat, + long: place.long, + name: place.name, + region: place.region, + score: place.score, + source: place.source, + type: place.type, + website: place.website, + wikipedia_query: place.wikipedia_query, + metadata: place.metadata, + photos: undefined, // Will be loaded lazily + })) + setPlaceResults(searchPlaces) } catch (error) { console.error('Error fetching places:', error) toast.error('Failed to load places') @@ -403,7 +425,11 @@ function SearchPageComponent({ parkGeometries }: SearchPageComponentProps) { }, []) const handlePlaceSelect = useCallback( - (index: number, place: PlacesInView | null, shouldCenterMap = false) => { + ( + index: number, + place: SearchPlaceInView | null, + shouldCenterMap = false + ) => { setActiveCardIndex(index) setSelectedPlace(place) @@ -420,12 +446,12 @@ function SearchPageComponent({ parkGeometries }: SearchPageComponentProps) { [] ) - const handlePlaceHover = useCallback((place: PlacesInView | null) => { + const handlePlaceHover = useCallback((place: SearchPlaceInView | null) => { setHoveredPlace(place) }, []) const handleMapMarkerClick = useCallback( - (index: number, place: PlacesInView) => { + (index: number, place: SearchPlaceInView) => { setActiveCardIndex(index) setSelectedPlace(place) // Note: We don't center the map here because the user clicked directly on the map diff --git a/components/search/result/Map.tsx b/components/search/result/Map.tsx index 78a2e19..f507afd 100644 --- a/components/search/result/Map.tsx +++ b/components/search/result/Map.tsx @@ -3,12 +3,12 @@ import { GeoJSONGeometry, ParkGeometry, - PlacesInView, getPlaceGeometry, getPlaceMetadata, } from '@/actions/explore.actions' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +import { SearchPlaceInView } from '@/types/search.types' import { FocusIcon } from 'lucide-react' import maplibregl, { LngLatBounds, Map as MapLibreMap } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' @@ -18,7 +18,7 @@ import styles from './Map.module.css' import { PopupContent } from './PopupContent' interface Props { - places: PlacesInView[] + places: SearchPlaceInView[] parkGeometries?: ParkGeometry[] onBoundsChange?: (bounds: { north: number @@ -27,8 +27,8 @@ interface Props { west: number }) => void className?: string - activePlace?: PlacesInView | null - onMarkerClick?: (index: number, place: PlacesInView) => void + activePlace?: SearchPlaceInView | null + onMarkerClick?: (index: number, place: SearchPlaceInView) => void onPopupClose?: () => void onMapReady?: (centerMap: (lat: number, lng: number) => void) => void } @@ -50,7 +50,7 @@ const Map: React.FC = ({ const activePopupRef = useRef(null) const geometryLayerRef = useRef(null) const geometryCacheRef = useRef<{ [key: string]: GeoJSONGeometry | null }>({}) - const placesRef = useRef(places) + const placesRef = useRef(places) const onBoundsChangeRef = useRef(onBoundsChange) const isUserInteractingRef = useRef(false) const isProgrammaticCloseRef = useRef(false) @@ -90,7 +90,7 @@ const Map: React.FC = ({ }) }, []) - const addGeometryLayer = useCallback(async (place: PlacesInView) => { + const addGeometryLayer = useCallback(async (place: SearchPlaceInView) => { if (!map.current || !map.current.isStyleLoaded()) { return } @@ -338,7 +338,7 @@ const Map: React.FC = ({ }) // Helper function to create and show popup - const showPopup = async (place: PlacesInView, isPinned: boolean) => { + const showPopup = async (place: SearchPlaceInView, isPinned: boolean) => { // Don't recreate popup if it's already open for the same place if ( activePopupRef.current && diff --git a/components/search/result/PlaceCard.tsx b/components/search/result/PlaceCard.tsx index fbd515c..8070a91 100644 --- a/components/search/result/PlaceCard.tsx +++ b/components/search/result/PlaceCard.tsx @@ -1,6 +1,6 @@ 'use client' -import { PlacesInView } from '@/actions/explore.actions' +import { getPlacePhotos } from '@/actions/search.actions' import { Button } from '@/components/ui/button' import { Card, @@ -9,14 +9,15 @@ import { CardHeader, CardTitle, } from '@/components/ui/card' +import { SearchPlaceInView, SearchPlacePhoto } from '@/types/search.types' import { motion } from 'framer-motion' import { ChevronLeft, ChevronRight, ImageIcon, Star } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' interface PlaceCardProps { - place: PlacesInView + place: SearchPlaceInView index: number isActive: boolean onClick: () => void @@ -33,27 +34,77 @@ export default function PlaceCard({ onMouseLeave, }: PlaceCardProps) { const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0) + const [photos, setPhotos] = useState( + place.photos + ) + const [isLoadingPhotos, setIsLoadingPhotos] = useState(false) + const cardRef = useRef(null) + const hasLoadedPhotos = useRef(false) + + // Lazy load photos when card becomes visible + useEffect(() => { + // If photos already exist, don't fetch again + if (photos || hasLoadedPhotos.current) { + return + } + + // Use Intersection Observer to detect when card is visible + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !hasLoadedPhotos.current) { + hasLoadedPhotos.current = true + setIsLoadingPhotos(true) + // Load only 3 photos max for place cards + getPlacePhotos(place.id, 3) + .then((loadedPhotos) => { + setPhotos(loadedPhotos) + }) + .catch((error) => { + console.error('Error loading photos:', error) + }) + .finally(() => { + setIsLoadingPhotos(false) + }) + observer.disconnect() + } + }) + }, + { + rootMargin: '50px', // Start loading 50px before card is visible + } + ) + + if (cardRef.current) { + observer.observe(cardRef.current) + } + + return () => { + observer.disconnect() + } + }, [place.id, photos]) const nextPhoto = (e: React.MouseEvent) => { e.stopPropagation() - if (place.photos && place.photos.length > 0) { + if (photos && photos.length > 0) { setCurrentPhotoIndex((prev) => - prev === place.photos!.length - 1 ? 0 : prev + 1 + prev === photos.length - 1 ? 0 : prev + 1 ) } } const prevPhoto = (e: React.MouseEvent) => { e.stopPropagation() - if (place.photos && place.photos.length > 0) { + if (photos && photos.length > 0) { setCurrentPhotoIndex((prev) => - prev === 0 ? place.photos!.length - 1 : prev - 1 + prev === 0 ? photos.length - 1 : prev - 1 ) } } return ( {/* Photo Carousel or Placeholder */}
- {place.photos && place.photos.length > 0 ? ( + {isLoadingPhotos ? ( +
+
+
+ ) : photos && photos.length > 0 ? ( <> {`${place.name} {/* Photo Navigation */} - {place.photos.length > 1 && ( + {photos.length > 1 && ( <> + + {/* Photo counter */} +
+ {selectedPhotoIndex + 1} of {validPhotos.length} +
+ + {/* Navigation arrows */} + {validPhotos.length > 1 && ( + <> + + + + )} + +
+ {`${placeName} +
+ + {validPhotos[selectedPhotoIndex].attribution && ( +
+ Photo by {validPhotos[selectedPhotoIndex].attribution} +
+ )} +
+ , + document.body + )} + + ) +} + diff --git a/next.config.ts b/next.config.ts index b1763ea..6e20b51 100644 --- a/next.config.ts +++ b/next.config.ts @@ -9,6 +9,12 @@ const nextConfig: NextConfig = { port: '', pathname: '/v1/**', }, + { + protocol: 'https', + hostname: 'upload.wikimedia.org', + port: '', + pathname: '/**', + }, ], }, } diff --git a/types/search.types.ts b/types/search.types.ts index 90c76d5..7d2006d 100644 --- a/types/search.types.ts +++ b/types/search.types.ts @@ -1,29 +1,47 @@ -export interface SearchFormValues { - locationType: 'current' | 'custom' - customLocation?: { - name: string - lat: number - lng: number - } - distance: string - transportType: 'public_transport' | 'car' | 'foot' | 'bike' - locationName?: string - activity?: string +import { Json } from '@/types/supabase' + +/** + * Photo structure from place_photos table + */ +export interface SearchPlacePhoto { + id: string + url: string + attribution: string | null + is_primary: boolean | null } -export interface SearchQuery { - location: { - latitude: number - longitude: number - } - distance: string - transportType: 'public_transport' | 'car' | 'foot' | 'bike' - locationName?: string +/** + * Place structure for search page with photos from place_photos table + */ +export interface SearchPlaceInView { + country: string + description: string + distance_km: number + id: string + lat: number + long: number + name: string + region: string + score: number + source: string + type: string + website: string + wikipedia_query: string + metadata?: Json + photos?: SearchPlacePhoto[] } +/** + * Search filters for the search page + */ export interface SearchFilters { distance: string transportType: 'public_transport' | 'car' | 'foot' | 'bike' activity?: string locationName?: string } + +/** + * Search form values (re-exported from validation) + */ +export type { SearchFormValues } from '@/validation/search-form.validation' diff --git a/types/supabase.ts b/types/supabase.ts index fd0218b..e640124 100644 --- a/types/supabase.ts +++ b/types/supabase.ts @@ -14,19 +14,111 @@ export type Database = { } public: { Tables: { + generated_places: { + Row: { + created_at: string + description: string | null + id: string + name: string | null + place_id: string | null + source_id: string | null + status: string | null + updated_at: string + } + Insert: { + created_at?: string + description?: string | null + id?: string + name?: string | null + place_id?: string | null + source_id?: string | null + status?: string | null + updated_at?: string + } + Update: { + created_at?: string + description?: string | null + id?: string + name?: string | null + place_id?: string | null + source_id?: string | null + status?: string | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "generated_places_place_id_fkey" + columns: ["place_id"] + isOneToOne: false + referencedRelation: "places" + referencedColumns: ["id"] + }, + { + foreignKeyName: "generated_places_source_id_fkey" + columns: ["source_id"] + isOneToOne: false + referencedRelation: "sources" + referencedColumns: ["id"] + }, + ] + } + place_photos: { + Row: { + attribution: string | null + created_at: string | null + id: string + is_primary: boolean | null + place_id: string + source: string + updated_at: string | null + url: string + } + Insert: { + attribution?: string | null + created_at?: string | null + id?: string + is_primary?: boolean | null + place_id: string + source: string + updated_at?: string | null + url: string + } + Update: { + attribution?: string | null + created_at?: string | null + id?: string + is_primary?: boolean | null + place_id?: string + source?: string + updated_at?: string | null + url?: string + } + Relationships: [ + { + foreignKeyName: "place_photos_place_id_fkey" + columns: ["place_id"] + isOneToOne: false + referencedRelation: "places" + referencedColumns: ["id"] + }, + ] + } places: { Row: { country: string | null created_at: string description: string | null enhancement_score: number | null - geometry: unknown | null + geometry: unknown id: string last_enhanced_at: string | null - location: unknown | null + last_website_analyzed_at: string | null + last_wikipedia_analyzed_at: string | null + location: unknown metadata: Json | null name: string | null osm_id: string | null + photos_fetched_at: string | null reddit_data: Json | null reddit_generated: string | null region: string | null @@ -39,8 +131,10 @@ export type Database = { updated_at: string | null website: string | null website_generated: string | null + website_places_generated: string[] | null website_raw: string | null wikipedia_generated: string | null + wikipedia_places_generated: string[] | null wikipedia_query: string | null wikipedia_raw: string | null } @@ -49,13 +143,16 @@ export type Database = { created_at?: string description?: string | null enhancement_score?: number | null - geometry?: unknown | null + geometry?: unknown id?: string last_enhanced_at?: string | null - location?: unknown | null + last_website_analyzed_at?: string | null + last_wikipedia_analyzed_at?: string | null + location?: unknown metadata?: Json | null name?: string | null osm_id?: string | null + photos_fetched_at?: string | null reddit_data?: Json | null reddit_generated?: string | null region?: string | null @@ -68,8 +165,10 @@ export type Database = { updated_at?: string | null website?: string | null website_generated?: string | null + website_places_generated?: string[] | null website_raw?: string | null wikipedia_generated?: string | null + wikipedia_places_generated?: string[] | null wikipedia_query?: string | null wikipedia_raw?: string | null } @@ -78,13 +177,16 @@ export type Database = { created_at?: string description?: string | null enhancement_score?: number | null - geometry?: unknown | null + geometry?: unknown id?: string last_enhanced_at?: string | null - location?: unknown | null + last_website_analyzed_at?: string | null + last_wikipedia_analyzed_at?: string | null + location?: unknown metadata?: Json | null name?: string | null osm_id?: string | null + photos_fetched_at?: string | null reddit_data?: Json | null reddit_generated?: string | null region?: string | null @@ -97,8 +199,10 @@ export type Database = { updated_at?: string | null website?: string | null website_generated?: string | null + website_places_generated?: string[] | null website_raw?: string | null wikipedia_generated?: string | null + wikipedia_places_generated?: string[] | null wikipedia_query?: string | null wikipedia_raw?: string | null } @@ -215,6 +319,33 @@ export type Database = { } Relationships: [] } + sources: { + Row: { + created_at: string + id: string + name: string | null + raw_content: string | null + updated_at: string | null + url: string + } + Insert: { + created_at?: string + id: string + name?: string | null + raw_content?: string | null + updated_at?: string | null + url: string + } + Update: { + created_at?: string + id?: string + name?: string | null + raw_content?: string | null + updated_at?: string | null + url?: string + } + Relationships: [] + } } Views: { [_ in never]: never @@ -274,7 +405,6 @@ export type Database = { Returns: { country: string description: string - distance_km: number id: string lat: number long: number From a7211d82d7ac4dbedcdf1f543a63c021b968eb6a Mon Sep 17 00:00:00 2001 From: Alexandre Phiev Date: Sun, 9 Nov 2025 11:24:43 +0100 Subject: [PATCH 2/3] fix popup and geometry behavior on click and hover on map --- components/search/result/Map.tsx | 573 +++++++++++++----- components/search/result/PopupContent.tsx | 20 +- .../result/details/SearchPlaceDetailView.tsx | 2 +- .../details/SearchPlacePhotoGallery.tsx | 205 ------- 4 files changed, 423 insertions(+), 377 deletions(-) delete mode 100644 components/search/result/details/SearchPlacePhotoGallery.tsx diff --git a/components/search/result/Map.tsx b/components/search/result/Map.tsx index f507afd..2c595a6 100644 --- a/components/search/result/Map.tsx +++ b/components/search/result/Map.tsx @@ -4,7 +4,6 @@ import { GeoJSONGeometry, ParkGeometry, getPlaceGeometry, - getPlaceMetadata, } from '@/actions/explore.actions' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -47,15 +46,24 @@ const Map: React.FC = ({ const map = useRef(null) const [mapLoaded, setMapLoaded] = useState(false) const [currentZoom, setCurrentZoom] = useState(4) - const activePopupRef = useRef(null) - const geometryLayerRef = useRef(null) + // Separate refs for pinned (clicked) and hover popups + const pinnedPopupRef = useRef(null) + const hoverPopupRef = useRef(null) + const pinnedPlaceIdRef = useRef(null) + const hoveredPlaceIdRef = useRef(null) + const geometryLayerRef = useRef(null) // For pinned geometry + const hoverGeometryLayerRef = useRef(null) // For hover geometry const geometryCacheRef = useRef<{ [key: string]: GeoJSONGeometry | null }>({}) const placesRef = useRef(places) const onBoundsChangeRef = useRef(onBoundsChange) const isUserInteractingRef = useRef(false) const isProgrammaticCloseRef = useRef(false) - const isPopupPinnedRef = useRef(false) - const currentHoveredPlaceRef = useRef(null) + const activePlaceRef = useRef( + activePlace + ) + // Track zoom level before centering, to restore it when going back + const zoomBeforeCenteringRef = useRef(null) + const shouldRestoreZoomRef = useRef(false) const mapStyle = 'https://api.maptiler.com/maps/topo-v2/style.json?key=Gxxj1jCvJhu2HSp6n0tp' @@ -69,6 +77,17 @@ const Map: React.FC = ({ const currentZoom = map.current.getZoom() // Use zoom 10 as minimum, but keep current zoom if it's higher const targetZoom = Math.max(currentZoom, 10) + + // Store original zoom if we're going to change it (for restoration when going back) + if (targetZoom > currentZoom) { + zoomBeforeCenteringRef.current = currentZoom + shouldRestoreZoomRef.current = true + } else { + // If zoom won't change, don't restore + shouldRestoreZoomRef.current = false + zoomBeforeCenteringRef.current = null + } + map.current.flyTo({ center: [lng, lat], zoom: targetZoom, @@ -253,6 +272,264 @@ const Map: React.FC = ({ geometryLayerRef.current = null }, []) + // Add hover geometry layer (temporary, shown on hover) + const addHoverGeometryLayer = useCallback( + async (place: SearchPlaceInView) => { + if (!map.current || !map.current.isStyleLoaded()) { + return + } + + // Don't show hover geometry if hovering on the pinned place + if (pinnedPlaceIdRef.current === place.id) { + return + } + + // Remove existing hover geometry if any + if (hoverGeometryLayerRef.current) { + const existingLayerId = hoverGeometryLayerRef.current + // Extract place ID from layer ID: "hover-geometry-{placeId}" -> "{placeId}" + const existingPlaceId = existingLayerId.replace('hover-geometry-', '') + const existingSourceId = `hover-geometry-source-${existingPlaceId}` + + try { + const layersToRemove = [ + existingLayerId, + `${existingLayerId}-fill`, + `${existingLayerId}-outline`, + ] + + layersToRemove.forEach((layer) => { + if (map.current!.getLayer(layer)) { + map.current!.removeLayer(layer) + } + }) + + if (map.current.getSource(existingSourceId)) { + map.current.removeSource(existingSourceId) + } + } catch (error) { + console.warn('Failed to remove hover geometry layer:', error) + } + + hoverGeometryLayerRef.current = null + } + + // Skip geometry for parks (same as pinned geometry) + if (place.type === 'national_park' || place.type === 'regional_park') { + return + } + + let geometry = geometryCacheRef.current[place.id] + + if (geometry === undefined) { + try { + geometry = await getPlaceGeometry(place.id) + geometryCacheRef.current[place.id] = geometry + } catch (error) { + console.warn('❌ Failed to fetch geometry:', error) + geometryCacheRef.current[place.id] = null + return + } + } + + if (!geometry) { + return + } + + const layerId = `hover-geometry-${place.id}` + const sourceId = `hover-geometry-source-${place.id}` + const opacity = place.type === 'regional_park' ? 0.1 : 0.3 + + try { + if (map.current.getSource(sourceId)) { + const existingLayers = [ + `${layerId}-fill`, + `${layerId}-outline`, + layerId, + ] + existingLayers.forEach((layer) => { + if (map.current!.getLayer(layer)) { + map.current!.removeLayer(layer) + } + }) + map.current.removeSource(sourceId) + } + + map.current.addSource(sourceId, { + type: 'geojson', + data: { + type: 'Feature', + properties: { + name: place.name, + id: place.id, + }, + geometry: geometry as GeoJSON.Geometry, + }, + }) + + if (geometry.type === 'Polygon' || geometry.type === 'MultiPolygon') { + map.current.addLayer({ + id: `${layerId}-fill`, + type: 'fill', + source: sourceId, + paint: { + 'fill-color': '#D3572C', + 'fill-opacity': opacity, + }, + }) + } else if ( + geometry.type === 'LineString' || + geometry.type === 'MultiLineString' + ) { + map.current.addLayer({ + id: layerId, + type: 'line', + source: sourceId, + paint: { + 'line-color': '#D3572C', + 'line-width': 3, + 'line-opacity': 0.8, + }, + }) + } else if ( + geometry.type === 'Point' || + geometry.type === 'MultiPoint' + ) { + map.current.addLayer({ + id: layerId, + type: 'circle', + source: sourceId, + paint: { + 'circle-color': '#D3572C', + 'circle-radius': 8, + 'circle-opacity': 0.6, + 'circle-stroke-color': '#D3572C', + 'circle-stroke-width': 2, + 'circle-stroke-opacity': 1, + }, + }) + } + + hoverGeometryLayerRef.current = layerId + } catch (error) { + console.warn('Failed to add hover geometry layer:', error) + } + }, + [] + ) + + // Remove hover geometry layer + const removeHoverGeometryLayer = useCallback(() => { + if (!map.current || !hoverGeometryLayerRef.current) return + + const layerId = hoverGeometryLayerRef.current + // Extract place ID from layer ID: "hover-geometry-{placeId}" -> "{placeId}" + const placeId = layerId.replace('hover-geometry-', '') + const sourceId = `hover-geometry-source-${placeId}` + + try { + // Remove all possible layer variations + const layersToRemove = [layerId, `${layerId}-fill`, `${layerId}-outline`] + + layersToRemove.forEach((layer) => { + if (map.current!.getLayer(layer)) { + map.current!.removeLayer(layer) + } + }) + + if (map.current.getSource(sourceId)) { + map.current.removeSource(sourceId) + } + } catch (error) { + console.warn('Failed to remove hover geometry layer:', error) + } + + hoverGeometryLayerRef.current = null + }, []) + + // Cleanup hover state function + const cleanupHoverState = useCallback(() => { + // Close hover popup + if (hoverPopupRef.current) { + isProgrammaticCloseRef.current = true + hoverPopupRef.current.remove() + hoverPopupRef.current = null + hoveredPlaceIdRef.current = null + isProgrammaticCloseRef.current = false + } + // Remove hover geometry + removeHoverGeometryLayer() + }, [removeHoverGeometryLayer]) + + // Helper function to create and show pinned popup (persistent, with close button) + const showPinnedPopup = useCallback( + async (place: SearchPlaceInView) => { + if (!map.current) return + + // Close previous pinned popup if exists + if (pinnedPopupRef.current) { + isProgrammaticCloseRef.current = true + pinnedPopupRef.current.remove() + pinnedPopupRef.current = null + pinnedPlaceIdRef.current = null + isProgrammaticCloseRef.current = false + removeGeometryLayer() + } + + // Close hover popup if hovering on the same place + if (hoverPopupRef.current && hoveredPlaceIdRef.current === place.id) { + isProgrammaticCloseRef.current = true + hoverPopupRef.current.remove() + hoverPopupRef.current = null + hoveredPlaceIdRef.current = null + isProgrammaticCloseRef.current = false + removeHoverGeometryLayer() + } + + pinnedPlaceIdRef.current = place.id + + if (place.type !== 'national_park' && place.type !== 'regional_park') { + addGeometryLayer(place).catch((error) => { + console.warn('Failed to add geometry layer:', error) + }) + } + + const popupNode = document.createElement('div') + const popupRoot = createRoot(popupNode) + popupRoot.render() + + const popup = new maplibregl.Popup({ + closeButton: true, // Close button for pinned popup + closeOnClick: false, + offset: [0, -10], + }) + .setLngLat([place.long, place.lat]) + .setDOMContent(popupNode) + .addTo(map.current) + + popup.on('close', () => { + pinnedPopupRef.current = null + pinnedPlaceIdRef.current = null + removeGeometryLayer() + // Call onPopupClose when user closes the pinned popup + if (onPopupClose && !isProgrammaticCloseRef.current) { + // Don't restore zoom when closing popup via close button (user intentionally unfocusing) + shouldRestoreZoomRef.current = false + zoomBeforeCenteringRef.current = null + onPopupClose() + } + }) + + pinnedPopupRef.current = popup + }, + [ + addGeometryLayer, + removeGeometryLayer, + removeHoverGeometryLayer, + onPopupClose, + ] + ) + const addPlacesLayer = useCallback(() => { if (!map.current || !map.current.isStyleLoaded()) return @@ -337,45 +614,53 @@ const Map: React.FC = ({ }, }) - // Helper function to create and show popup - const showPopup = async (place: SearchPlaceInView, isPinned: boolean) => { - // Don't recreate popup if it's already open for the same place - if ( - activePopupRef.current && - currentHoveredPlaceRef.current === place.id - ) { - // If clicking on an already hovered place, just pin it - if (isPinned) { - isPopupPinnedRef.current = true - } + // Helper function to create and show hover popup (temporary, no close button) + const showHoverPopup = async (place: SearchPlaceInView) => { + // Don't show hover popup if hovering on the pinned place + if (pinnedPlaceIdRef.current === place.id) { return } - // Remove existing popup - if (activePopupRef.current) { + // Store the place ID we're about to show hover for + const targetPlaceId = place.id + + // Close existing hover popup and geometry + if (hoverPopupRef.current) { isProgrammaticCloseRef.current = true - activePopupRef.current.remove() - activePopupRef.current = null + hoverPopupRef.current.remove() + hoverPopupRef.current = null + hoveredPlaceIdRef.current = null isProgrammaticCloseRef.current = false } + removeHoverGeometryLayer() + + // Verify we're still hovering on the same place after async cleanup + if (!map.current || !map.current.isStyleLoaded()) { + return + } - currentHoveredPlaceRef.current = place.id - isPopupPinnedRef.current = isPinned + hoveredPlaceIdRef.current = targetPlaceId + // Add hover geometry (only for non-park places - parks show via parkGeometries layer) if (place.type !== 'national_park' && place.type !== 'regional_park') { - addGeometryLayer(place).catch((error) => { - console.warn('Failed to add geometry layer:', error) - }) + await addHoverGeometryLayer(place) + // Double-check we're still on the same place after async geometry fetch + if (hoveredPlaceIdRef.current !== targetPlaceId) { + return + } + } + + // Verify we're still hovering on the same place before showing popup + if (hoveredPlaceIdRef.current !== targetPlaceId) { + return } const popupNode = document.createElement('div') const popupRoot = createRoot(popupNode) - popupRoot.render( - - ) + popupRoot.render() const popup = new maplibregl.Popup({ - closeButton: isPinned, + closeButton: false, // No close button for hover popup closeOnClick: false, offset: [0, -10], }) @@ -383,46 +668,30 @@ const Map: React.FC = ({ .setDOMContent(popupNode) .addTo(map.current!) - popup.on('close', () => { - activePopupRef.current = null - currentHoveredPlaceRef.current = null - isPopupPinnedRef.current = false - removeGeometryLayer() - // Only call onPopupClose if this is a user-initiated close (not programmatic) - if (onPopupClose && !isProgrammaticCloseRef.current) { - onPopupClose() - } - }) - - activePopupRef.current = popup + // Verify we're still on the same place before setting the ref + if (hoveredPlaceIdRef.current === targetPlaceId) { + popup.on('close', () => { + // Only clear if this is still the current hover popup + if (hoverPopupRef.current === popup) { + hoverPopupRef.current = null + hoveredPlaceIdRef.current = null + removeHoverGeometryLayer() + } + }) - try { - const metadata = await getPlaceMetadata(place.id) - if (popup.isOpen()) { - popupRoot.render( - - ) - } - } catch (error) { - console.error('Failed to fetch metadata for place:', error) + hoverPopupRef.current = popup + } else { + // We moved to a different place, clean up this popup + popup.remove() } } - // Hover to show popup + // Hover to show popup (temporary, can coexist with pinned popup) map.current.on('mouseenter', layerId, async (e) => { if (map.current) { map.current.getCanvas().style.cursor = 'pointer' } - // Don't show hover popup if there's a pinned popup - if (isPopupPinnedRef.current) { - return - } - if (!e.features || e.features.length === 0) { return } @@ -435,10 +704,11 @@ const Map: React.FC = ({ return } - await showPopup(place, false) + // Show hover popup (will not show if hovering on pinned place) + await showHoverPopup(place) }) - // Click to pin popup + // Click to pin popup and select place map.current.on('click', layerId, async (e) => { if (!e.features || e.features.length === 0) { return @@ -453,53 +723,61 @@ const Map: React.FC = ({ return } + // Pin the popup (this will show it with a close button and close previous pinned popup) + await showPinnedPopup(place) + + // Also trigger the marker click handler to open detail view if (onMarkerClick) { const placeIndex = placesRef.current.findIndex((p) => p.id === placeId) if (placeIndex >= 0) { onMarkerClick(placeIndex, place) - return } } - - await showPopup(place, true) }) - // Remove hover popup when leaving if not pinned + // Remove hover popup when leaving (only hover popup, not pinned) map.current.on('mouseleave', layerId, () => { if (map.current) { map.current.getCanvas().style.cursor = '' } - // Only close if popup is not pinned - if (!isPopupPinnedRef.current && activePopupRef.current) { - isProgrammaticCloseRef.current = true - activePopupRef.current.remove() - activePopupRef.current = null - currentHoveredPlaceRef.current = null - isProgrammaticCloseRef.current = false - removeGeometryLayer() - } + // Close hover popup when mouse leaves (pinned popup stays) + cleanupHoverState() }) - // Add click handler for map background to clear pinned popup + // Add click handler for map background to clear pinned popup and deselect place map.current.on('click', (e) => { const features = map.current!.queryRenderedFeatures(e.point, { layers: [layerId], }) - if (features.length === 0 && isPopupPinnedRef.current) { + // If clicking on empty map area and popup is pinned, close it and deselect + if (features.length === 0 && pinnedPopupRef.current) { + // Don't restore zoom when clicking map to unfocus + shouldRestoreZoomRef.current = false + zoomBeforeCenteringRef.current = null + removeGeometryLayer() - if (activePopupRef.current) { - isProgrammaticCloseRef.current = true - activePopupRef.current.remove() - activePopupRef.current = null - currentHoveredPlaceRef.current = null - isPopupPinnedRef.current = false - isProgrammaticCloseRef.current = false + isProgrammaticCloseRef.current = true + pinnedPopupRef.current.remove() + pinnedPopupRef.current = null + pinnedPlaceIdRef.current = null + isProgrammaticCloseRef.current = false + // Deselect the place in the left panel + if (onPopupClose) { + onPopupClose() } } }) - }, [addGeometryLayer, onMarkerClick, onPopupClose, removeGeometryLayer]) + }, [ + onMarkerClick, + onPopupClose, + removeGeometryLayer, + removeHoverGeometryLayer, + addHoverGeometryLayer, + showPinnedPopup, + cleanupHoverState, + ]) const onViewAllPlaces = useCallback(() => { if (!map.current || places.length === 0) { @@ -583,6 +861,17 @@ const Map: React.FC = ({ } return () => { + // Clean up all hover state + if (hoverPopupRef.current) { + isProgrammaticCloseRef.current = true + hoverPopupRef.current.remove() + hoverPopupRef.current = null + hoveredPlaceIdRef.current = null + isProgrammaticCloseRef.current = false + } + if (hoverGeometryLayerRef.current) { + removeHoverGeometryLayer() + } if (map.current) { removeGeometryLayer() map.current.remove() @@ -598,9 +887,12 @@ const Map: React.FC = ({ useEffect(() => { if (!map.current || !mapLoaded) return + // Clean up any hover state when places change + cleanupHoverState() + placesRef.current = places addPlacesLayer() - }, [places, mapLoaded, addPlacesLayer]) + }, [places, mapLoaded, addPlacesLayer, cleanupHoverState]) // Add park geometries layer with pre-loaded data useEffect(() => { @@ -711,6 +1003,11 @@ const Map: React.FC = ({ } }, [parkGeometries, mapLoaded]) + // Update activePlaceRef when activePlace changes + useEffect(() => { + activePlaceRef.current = activePlace + }, [activePlace]) + // Handle activePlace prop to show/hide popup (from card hover/click) useEffect(() => { if (!map.current || !mapLoaded) { @@ -718,87 +1015,43 @@ const Map: React.FC = ({ } if (activePlace) { - // Don't override a pinned popup + // Don't recreate pinned popup if it's already open for this place if ( - isPopupPinnedRef.current && - currentHoveredPlaceRef.current === activePlace.id + pinnedPopupRef.current && + pinnedPlaceIdRef.current === activePlace.id ) { return } - // Close existing popup without triggering onPopupClose callback - if (activePopupRef.current) { + // Show pinned popup for selected place (from card click) + showPinnedPopup(activePlace).catch((error) => { + console.error('Failed to show pinned popup:', error) + }) + } else { + // When activePlace becomes null, close pinned popup and clean up geometry + if (pinnedPopupRef.current) { isProgrammaticCloseRef.current = true - activePopupRef.current.remove() - activePopupRef.current = null + pinnedPopupRef.current.remove() + pinnedPopupRef.current = null + pinnedPlaceIdRef.current = null isProgrammaticCloseRef.current = false - } - - currentHoveredPlaceRef.current = activePlace.id - // Treat activePlace changes as hover (not pinned) - isPopupPinnedRef.current = false - - const popupNode = document.createElement('div') - const popupRoot = createRoot(popupNode) - popupRoot.render( - - ) - - const popup = new maplibregl.Popup({ - closeButton: false, // No close button for hover popup - closeOnClick: false, - offset: [0, -10], - }) - .setLngLat([activePlace.long, activePlace.lat]) - .setDOMContent(popupNode) - .addTo(map.current!) - - popup.on('close', () => { - activePopupRef.current = null - currentHoveredPlaceRef.current = null - isPopupPinnedRef.current = false removeGeometryLayer() - // Only call onPopupClose if this is a user-initiated close (not programmatic) - if (onPopupClose && !isProgrammaticCloseRef.current) { - onPopupClose() - } - }) - - activePopupRef.current = popup + } + // Restore zoom level if it was changed when centering on the place if ( - activePlace.type !== 'national_park' && - activePlace.type !== 'regional_park' + shouldRestoreZoomRef.current && + zoomBeforeCenteringRef.current !== null && + map.current ) { - addGeometryLayer(activePlace).catch((error) => { - console.warn('Failed to add geometry layer:', error) - }) - } - - getPlaceMetadata(activePlace.id) - .then((metadata) => { - if (popup.isOpen()) { - popupRoot.render( - - ) - } + const originalZoom = zoomBeforeCenteringRef.current + map.current.flyTo({ + zoom: originalZoom, + duration: 500, }) - .catch((error) => { - console.error('Failed to fetch metadata for place:', error) - }) - } else { - // When activePlace becomes null, close popup and clean up geometry (only if not pinned) - if (activePopupRef.current && !isPopupPinnedRef.current) { - isProgrammaticCloseRef.current = true - activePopupRef.current.remove() - activePopupRef.current = null - currentHoveredPlaceRef.current = null - isProgrammaticCloseRef.current = false - removeGeometryLayer() + // Reset the restore flag + shouldRestoreZoomRef.current = false + zoomBeforeCenteringRef.current = null } } }, [ @@ -807,20 +1060,22 @@ const Map: React.FC = ({ addGeometryLayer, removeGeometryLayer, onPopupClose, + showPinnedPopup, ]) // Clean up on unmount useEffect(() => { return () => { - if (activePopupRef.current) { + if (pinnedPopupRef.current) { isProgrammaticCloseRef.current = true - activePopupRef.current.remove() - activePopupRef.current = null + pinnedPopupRef.current.remove() + pinnedPopupRef.current = null isProgrammaticCloseRef.current = false } removeGeometryLayer() + removeHoverGeometryLayer() } - }, [removeGeometryLayer]) + }, [removeGeometryLayer, removeHoverGeometryLayer]) return (
- score?: number -}) => { - const tagEntries = tags ? Object.entries(tags).slice(0, 2) : [] +export const PopupContent = ({ place }: { place: SearchPlaceInView }) => { + const tagEntries = (place.metadata as { tags?: Record })?.tags + ? Object.entries( + (place.metadata as { tags?: Record })?.tags || {} + ).slice(0, 2) + : [] return (
@@ -17,9 +13,9 @@ export const PopupContent = ({
{place.name || 'Unnamed Place'}
- {score !== undefined && ( + {place.score !== undefined && ( - {score.toFixed(1)} + {place.score.toFixed(1)} )}
diff --git a/components/search/result/details/SearchPlaceDetailView.tsx b/components/search/result/details/SearchPlaceDetailView.tsx index c41cf3f..b00ba20 100644 --- a/components/search/result/details/SearchPlaceDetailView.tsx +++ b/components/search/result/details/SearchPlaceDetailView.tsx @@ -165,7 +165,7 @@ export default function SearchPlaceDetailView({
Wikipedia: ( - null - ) - const [imageErrors, setImageErrors] = useState>(new Set()) - - // Handle image load errors - const handleImageError = (photoUrl: string) => { - setImageErrors((prev) => new Set([...prev, photoUrl])) - } - - // Filter out photos with errors - const validPhotos = photos.filter((photo) => !imageErrors.has(photo.url)) - - if (!validPhotos.length) { - return null - } - - return ( - <> - - - - - Photos - - - - - - {validPhotos.map((photo, index) => ( - -
setSelectedPhotoIndex(index)} - > - {`${placeName} handleImageError(photo.url)} - /> -
-
- ))} -
- {validPhotos.length > 2 && ( - <> - - - - )} -
- - {validPhotos.some((photo) => photo.attribution) && ( -
-

- {validPhotos - .filter((p) => p.attribution) - .map((p) => p.attribution) - .filter(Boolean) - .join(', ')} -

-
- )} -
-
- - {/* Photo Modal with Navigation - Rendered via Portal */} - {selectedPhotoIndex !== null && - typeof document !== 'undefined' && - createPortal( -
setSelectedPhotoIndex(null)} - > -
e.stopPropagation()} - > - - - {/* Photo counter */} -
- {selectedPhotoIndex + 1} of {validPhotos.length} -
- - {/* Navigation arrows */} - {validPhotos.length > 1 && ( - <> - - - - )} - -
- {`${placeName} -
- - {validPhotos[selectedPhotoIndex].attribution && ( -
- Photo by {validPhotos[selectedPhotoIndex].attribution} -
- )} -
-
, - document.body - )} - - ) -} - From b21dba0955ea5024b1d765492f42a6a79da73560 Mon Sep 17 00:00:00 2001 From: Alexandre Phiev Date: Mon, 10 Nov 2025 09:25:04 +0100 Subject: [PATCH 3/3] fix: code cleaning --- actions/search.actions.ts | 41 +++---- .../result/details/PlacePhotoGallery.tsx | 64 ++++++++-- components/explore/ExploreMap.tsx | 24 +--- components/search/SearchPageComponent.tsx | 110 +++++++++--------- components/search/result/Map.tsx | 24 +--- components/search/result/PlaceCard.tsx | 10 +- components/search/result/PopupContent.tsx | 19 --- .../result/details/SearchPlaceDetailView.tsx | 1 + types/result.types.ts | 1 + types/search.types.ts | 29 ++--- 10 files changed, 151 insertions(+), 172 deletions(-) diff --git a/actions/search.actions.ts b/actions/search.actions.ts index 6531065..44d87d0 100644 --- a/actions/search.actions.ts +++ b/actions/search.actions.ts @@ -1,10 +1,14 @@ 'use server' import { SearchPlaceInView, SearchPlacePhoto } from '@/types/search.types' -import { Json } from '@/types/supabase' +import { Database } from '@/types/supabase' import { distanceToRadiusKm } from '@/utils/distance.utils' import { createClient } from '@/utils/supabase/server' +// Type for the RPC function return value +type SearchPlacesByLocationResult = + Database['public']['Functions']['search_places_by_location']['Returns'][number] + interface SearchPlacesParams { latitude: number longitude: number @@ -26,7 +30,7 @@ export async function getPlacePhotos( let query = supabase .from('place_photos') - .select('id, place_id, url, attribution, is_primary') + .select('id, place_id, url, attribution, is_primary, source') .eq('place_id', placeId) .order('is_primary', { ascending: false, nullsFirst: false }) .order('created_at', { ascending: true }) @@ -48,14 +52,15 @@ export async function getPlacePhotos( url: photo.url, attribution: photo.attribution, is_primary: photo.is_primary, + source: photo.source, })) || [] ) } /** * Search places by location using RPC function - * Returns only the fields returned by the RPC function (no photos) - * Photos should be loaded lazily at the component level + * Returns SearchPlaceInView with all fields from search_places_by_location RPC (including distance_km) + * Photos are loaded lazily at the component level */ export async function searchPlacesAction( params: SearchPlacesParams @@ -64,6 +69,7 @@ export async function searchPlacesAction( const { latitude, longitude, radiusKm, limit = 20 } = params // Fetch places using RPC function - returns exactly what the RPC returns + // This includes distance_km calculated by ST_Distance in the SQL function const { data: placesData, error: placesError } = await supabase.rpc( 'search_places_by_location', { @@ -85,24 +91,15 @@ export async function searchPlacesAction( } // Map RPC return type to SearchPlaceInView (photos will be loaded lazily) - const places: SearchPlaceInView[] = placesData.map((place) => ({ - id: place.id, - country: place.country, - description: place.description, - distance_km: place.distance_km, - lat: place.lat, - long: place.long, - name: place.name, - region: place.region, - score: place.score, - source: place.source, - type: place.type, - website: place.website, - wikipedia_query: place.wikipedia_query, - metadata: place.metadata as Json | undefined, - // photos will be undefined initially, loaded lazily - photos: undefined, - })) + // The RPC function already returns all the fields we need including distance_km, + // we just add photos field which is undefined initially + const places: SearchPlaceInView[] = placesData.map( + (place: SearchPlacesByLocationResult) => ({ + ...place, + // photos will be undefined initially, loaded lazily + photos: undefined, + }) + ) return places } diff --git a/components/discovery/result/details/PlacePhotoGallery.tsx b/components/discovery/result/details/PlacePhotoGallery.tsx index 6b8ab75..21dc960 100644 --- a/components/discovery/result/details/PlacePhotoGallery.tsx +++ b/components/discovery/result/details/PlacePhotoGallery.tsx @@ -24,17 +24,32 @@ interface PlacePhotoGalleryProps { /** * Strip HTML tags from a string and return plain text * Also decodes HTML entities like &, <, etc. + * Uses DOMParser for safe HTML parsing without XSS risk */ function stripHtmlTags(html: string): string { if (typeof document !== 'undefined') { - // Browser environment - use DOM parser for better accuracy - const tmp = document.createElement('div') - tmp.innerHTML = html - const text = tmp.textContent || tmp.innerText || '' - // Decode HTML entities - const decoded = document.createElement('textarea') - decoded.innerHTML = text - return decoded.value.trim() + // Browser environment - use DOMParser for safe parsing (prevents XSS) + try { + const parser = new DOMParser() + const doc = parser.parseFromString(html, 'text/html') + const text = doc.body.textContent || doc.body.innerText || '' + // Decode HTML entities safely using textarea + const decoded = document.createElement('textarea') + decoded.textContent = text + return decoded.value.trim() + } catch { + // Fallback to regex if DOMParser fails + let cleaned = html.replace(/<[^>]*>/g, '') + cleaned = cleaned + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim() + return cleaned + } } // Server-side fallback - basic regex stripping and entity decoding let cleaned = html.replace(/<[^>]*>/g, '') // Remove HTML tags @@ -72,6 +87,33 @@ export default function PlacePhotoGallery({ return null } + // Determine the source to display - use the most common source, or first photo's source, or fallback to Google Maps + const getPhotoSource = (): string => { + const sources = validPhotos + .map((photo) => photo.source) + .filter((source): source is string => Boolean(source)) + + if (sources.length === 0) { + return 'Google Maps' // Fallback + } + + // Count occurrences of each source + const sourceCounts = sources.reduce( + (acc, source) => { + acc[source] = (acc[source] || 0) + 1 + return acc + }, + {} as Record + ) + + // Return the most common source, or first source if all are equal + return Object.entries(sourceCounts).reduce((a, b) => + sourceCounts[a[0]] > sourceCounts[b[0]] ? a : b + )[0] + } + + const photoSource = getPhotoSource() + return ( <> @@ -121,15 +163,15 @@ export default function PlacePhotoGallery({ {validPhotos.some((photo) => photo.attribution) && (
- {googleMapsUri ? ( + {googleMapsUri && photoSource === 'Google Maps' ? (

- Source: Google Maps + Source: {photoSource}

) : (

- Source: Google Maps + Source: {photoSource}

)}
diff --git a/components/explore/ExploreMap.tsx b/components/explore/ExploreMap.tsx index 18d6575..ce26165 100644 --- a/components/explore/ExploreMap.tsx +++ b/components/explore/ExploreMap.tsx @@ -598,16 +598,8 @@ const ExploreMap: React.FC = ({ }, filter: [ 'any', - [ - 'all', - ['==', ['get', 'type'], 'national_park'], - ['>=', ['zoom'], 6], - ], - [ - 'all', - ['==', ['get', 'type'], 'regional_park'], - ['>=', ['zoom'], 6], - ], + ['==', ['get', 'type'], 'national_park'], + ['==', ['get', 'type'], 'regional_park'], ], }) @@ -622,16 +614,8 @@ const ExploreMap: React.FC = ({ }, filter: [ 'any', - [ - 'all', - ['==', ['get', 'type'], 'national_park'], - ['>=', ['zoom'], 6], - ], - [ - 'all', - ['==', ['get', 'type'], 'regional_park'], - ['>=', ['zoom'], 6], - ], + ['==', ['get', 'type'], 'national_park'], + ['==', ['get', 'type'], 'regional_park'], ], }) } diff --git a/components/search/SearchPageComponent.tsx b/components/search/SearchPageComponent.tsx index fcec19d..f605fa4 100644 --- a/components/search/SearchPageComponent.tsx +++ b/components/search/SearchPageComponent.tsx @@ -1,10 +1,6 @@ 'use client' -import { - BoundingBox, - getPlacesInBounds, - ParkGeometry, -} from '@/actions/explore.actions' +import { BoundingBox, ParkGeometry } from '@/actions/explore.actions' import { searchPlacesAction } from '@/actions/search.actions' import { SearchFormModal } from '@/components/search/form/SearchFormModal' import SearchFiltersBar from '@/components/search/result/SearchFiltersBar' @@ -293,26 +289,29 @@ function SearchPageComponent({ parkGeometries }: SearchPageComponentProps) { try { isLoadingRef.current = true setIsLoading(true) - const placesInBounds = await getPlacesInBounds(bounds) - // Convert PlacesInView[] to SearchPlaceInView[] - const searchPlaces: SearchPlaceInView[] = placesInBounds.map((place) => ({ - id: place.id, - country: place.country, - description: place.description, - distance_km: place.distance_km ?? 0, // Default to 0 if undefined - lat: place.lat, - long: place.long, - name: place.name, - region: place.region, - score: place.score, - source: place.source, - type: place.type, - website: place.website, - wikipedia_query: place.wikipedia_query, - metadata: place.metadata, - photos: undefined, // Will be loaded lazily - })) - setPlaceResults(searchPlaces) + // Use search_places_by_location to ensure distance_km is always available + // Calculate center and radius from bounds + const center = { + latitude: (bounds.north + bounds.south) / 2, + longitude: (bounds.east + bounds.west) / 2, + } + // Calculate approximate radius in km from bounds + // Using Haversine formula approximation for diagonal distance + const latDiff = bounds.north - bounds.south + const lngDiff = bounds.east - bounds.west + const avgLat = center.latitude + const latKm = latDiff * 111 // 1 degree latitude ≈ 111 km + const lngKm = lngDiff * 111 * Math.cos((avgLat * Math.PI) / 180) + const radiusKm = Math.max(latKm, lngKm) / 2 // Use half of the larger dimension + + // Use search_places_by_location to get places with distance_km + const places = await searchPlacesAction({ + latitude: center.latitude, + longitude: center.longitude, + radiusKm: Math.max(radiusKm, 10), // Minimum 10km radius + limit: 50, // Show more places when panning + }) + setPlaceResults(places) } catch (error) { console.error('Error fetching places:', error) toast.error('Failed to load places') @@ -326,40 +325,43 @@ function SearchPageComponent({ parkGeometries }: SearchPageComponentProps) { currentFiltersRef.current = currentFilters }, [currentFilters]) - const handleBoundsChange = useCallback((bounds: BoundingBox) => { - if (boundsChangeTimeoutRef.current) { - clearTimeout(boundsChangeTimeoutRef.current) - } + const handleBoundsChange = useCallback( + (bounds: BoundingBox) => { + if (boundsChangeTimeoutRef.current) { + clearTimeout(boundsChangeTimeoutRef.current) + } - const lastBounds = lastBoundsRef.current - if (lastBounds) { - const latDiff = - Math.abs(bounds.north - lastBounds.north) + - Math.abs(bounds.south - lastBounds.south) - const lngDiff = - Math.abs(bounds.east - lastBounds.east) + - Math.abs(bounds.west - lastBounds.west) - const threshold = 0.001 - - if (latDiff < threshold && lngDiff < threshold) { - return + const lastBounds = lastBoundsRef.current + if (lastBounds) { + const latDiff = + Math.abs(bounds.north - lastBounds.north) + + Math.abs(bounds.south - lastBounds.south) + const lngDiff = + Math.abs(bounds.east - lastBounds.east) + + Math.abs(bounds.west - lastBounds.west) + const threshold = 0.001 + + if (latDiff < threshold && lngDiff < threshold) { + return + } } - } - boundsChangeTimeoutRef.current = setTimeout(() => { - lastBoundsRef.current = bounds + boundsChangeTimeoutRef.current = setTimeout(() => { + lastBoundsRef.current = bounds - if (!currentFiltersRef.current) { - fetchPlacesInBounds(bounds) - } else { - const center = { - latitude: (bounds.north + bounds.south) / 2, - longitude: (bounds.east + bounds.west) / 2, + if (!currentFiltersRef.current) { + fetchPlacesInBounds(bounds) + } else { + const center = { + latitude: (bounds.north + bounds.south) / 2, + longitude: (bounds.east + bounds.west) / 2, + } + performFilteredSearch(currentFiltersRef.current, center) } - performFilteredSearch(currentFiltersRef.current, center) - } - }, 1000) - }, []) + }, 1000) + }, + [fetchPlacesInBounds, performFilteredSearch] + ) function onSubmit(values: SearchFormValues) { let selectedLocation: { latitude: number; longitude: number } diff --git a/components/search/result/Map.tsx b/components/search/result/Map.tsx index 2c595a6..07b34ce 100644 --- a/components/search/result/Map.tsx +++ b/components/search/result/Map.tsx @@ -961,16 +961,8 @@ const Map: React.FC = ({ }, filter: [ 'any', - [ - 'all', - ['==', ['get', 'type'], 'national_park'], - ['>=', ['zoom'], 6], - ], - [ - 'all', - ['==', ['get', 'type'], 'regional_park'], - ['>=', ['zoom'], 6], - ], + ['==', ['get', 'type'], 'national_park'], + ['==', ['get', 'type'], 'regional_park'], ], }) @@ -985,16 +977,8 @@ const Map: React.FC = ({ }, filter: [ 'any', - [ - 'all', - ['==', ['get', 'type'], 'national_park'], - ['>=', ['zoom'], 6], - ], - [ - 'all', - ['==', ['get', 'type'], 'regional_park'], - ['>=', ['zoom'], 6], - ], + ['==', ['get', 'type'], 'national_park'], + ['==', ['get', 'type'], 'regional_park'], ], }) } diff --git a/components/search/result/PlaceCard.tsx b/components/search/result/PlaceCard.tsx index 8070a91..1edb6a5 100644 --- a/components/search/result/PlaceCard.tsx +++ b/components/search/result/PlaceCard.tsx @@ -81,6 +81,7 @@ export default function PlaceCard({ return () => { observer.disconnect() + hasLoadedPhotos.current = false // Reset on unmount } }, [place.id, photos]) @@ -209,15 +210,6 @@ export default function PlaceCard({
- {/* Duration Information - Single Row */} - {place.distance_km && ( -
- - Distance: {place.distance_km} km - -
- )} - {/* Best Time to Visit - Bottom with 3-line limit */} {place.wikipedia_query && (
diff --git a/components/search/result/PopupContent.tsx b/components/search/result/PopupContent.tsx index 1d93275..da07ce9 100644 --- a/components/search/result/PopupContent.tsx +++ b/components/search/result/PopupContent.tsx @@ -1,12 +1,6 @@ import { SearchPlaceInView } from '@/types/search.types' export const PopupContent = ({ place }: { place: SearchPlaceInView }) => { - const tagEntries = (place.metadata as { tags?: Record })?.tags - ? Object.entries( - (place.metadata as { tags?: Record })?.tags || {} - ).slice(0, 2) - : [] - return (
@@ -19,19 +13,6 @@ export const PopupContent = ({ place }: { place: SearchPlaceInView }) => { )}
- {tagEntries.length > 0 && ( -
- {tagEntries.map(([key, value], index) => ( - - {key}: {value} - - ))} -
- )}
) } diff --git a/components/search/result/details/SearchPlaceDetailView.tsx b/components/search/result/details/SearchPlaceDetailView.tsx index b00ba20..7991cb0 100644 --- a/components/search/result/details/SearchPlaceDetailView.tsx +++ b/components/search/result/details/SearchPlaceDetailView.tsx @@ -114,6 +114,7 @@ export default function SearchPlaceDetailView({ photos={photos.map((photo) => ({ url: photo.url, attribution: photo.attribution || undefined, + source: photo.source, }))} placeName={place.name} /> diff --git a/types/result.types.ts b/types/result.types.ts index 17281e2..60c6d2b 100644 --- a/types/result.types.ts +++ b/types/result.types.ts @@ -1,6 +1,7 @@ export interface PlacePhoto { url: string attribution?: string + source?: string } export interface PlaceReview { diff --git a/types/search.types.ts b/types/search.types.ts index 7d2006d..bf09fe7 100644 --- a/types/search.types.ts +++ b/types/search.types.ts @@ -1,4 +1,4 @@ -import { Json } from '@/types/supabase' +import { Database } from '@/types/supabase' /** * Photo structure from place_photos table @@ -8,26 +8,21 @@ export interface SearchPlacePhoto { url: string attribution: string | null is_primary: boolean | null + source: string } /** - * Place structure for search page with photos from place_photos table + * Supabase generated type from search_places_by_location RPC function + * This ensures type safety and stays in sync with the database schema */ -export interface SearchPlaceInView { - country: string - description: string - distance_km: number - id: string - lat: number - long: number - name: string - region: string - score: number - source: string - type: string - website: string - wikipedia_query: string - metadata?: Json +export type SearchPlaceByLocation = + Database['public']['Functions']['search_places_by_location']['Returns'][number] + +/** + * Place structure for search page - extends SearchPlaceByLocation with photos + * Photos are loaded lazily at the component level + */ +export interface SearchPlaceInView extends SearchPlaceByLocation { photos?: SearchPlacePhoto[] }