"use client"; import { useEffect, useState, useRef } from "react"; import { importLibrary, setOptions } from "@googlemaps/js-api-loader"; import { Location } from "@prisma/client"; import { gpx } from "@tmcw/togeojson"; export default function Gallery() { const [locations, setLocations] = useState([]); const [filteredLocations, setFilteredLocations] = useState([]); const [selectedLocation, setSelectedLocation] = useState(null); const [search, setSearch] = useState(""); const [viewingStreetView, setViewingStreetView] = useState(false); const [iframeUrl, setIframeUrl] = useState(null); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [thumbnails, setThumbnails] = useState>({}); const [toastMessage, setToastMessage] = useState(null); const [totalMiles, setTotalMiles] = useState(0); const [geometryLoaded, setGeometryLoaded] = useState(false); const [runtimeApiKey, setRuntimeApiKey] = useState(""); const locationsRef = useRef([]); const mapRef = useRef(null); const miniMapRef = useRef(null); const googleMapObj = useRef(null); const miniMapObj = useRef(null); const miniMapMarkerObj = useRef(null); const svServiceRef = useRef(null); const activePolylinesRef = useRef<{ id: string; polyline: google.maps.Polyline; bounds: google.maps.LatLngBounds; firstCoord: google.maps.LatLngLiteral }[]>([]); // Fetch runtime API key from server (GOOGLE_MAPS_API_KEY env var) useEffect(() => { fetch("/api/config") .then((res) => res.json()) .then((data) => { if (data.apiKey) setRuntimeApiKey(data.apiKey); }) .catch((err) => console.error("Error fetching config:", err)); }, []); // Fetch locations useEffect(() => { const queryParams = new URLSearchParams(window.location.search); const initialLocId = queryParams.get("locationId"); fetch("/api/locations") .then((res) => res.json()) .then((data) => { if (Array.isArray(data)) { setLocations(data); locationsRef.current = data; setFilteredLocations(data); if (data.length > 0) { const targetLoc = data.find((l: Location) => l.id === initialLocId) || data[0]; setSelectedLocation(targetLoc); } } else { console.error("API returned non-array data:", data); } }) .catch((err) => console.error("Error fetching locations:", err)); }, []); // Filter locations useEffect(() => { const term = search.toLowerCase(); setFilteredLocations( locations.filter((loc) => { return loc.title.toLowerCase().includes(term) || loc.city.toLowerCase().includes(term) || (loc.tags && loc.tags.toLowerCase().includes(term)); }) ); }, [search, locations]); // Load Google Maps API & Initialize useEffect(() => { if (!runtimeApiKey) return; setOptions({ key: runtimeApiKey, v: "weekly", }); importLibrary("maps").then(async () => { const { Map } = await google.maps.importLibrary("maps") as google.maps.MapsLibrary; const { AdvancedMarkerElement } = await google.maps.importLibrary("marker") as google.maps.MarkerLibrary; const { StreetViewPanorama } = await google.maps.importLibrary("streetView") as google.maps.StreetViewLibrary; await google.maps.importLibrary("geometry"); // For distance calculations setGeometryLoaded(true); // Initialize Street View Service svServiceRef.current = new google.maps.StreetViewService(); // Initialize Map googleMapObj.current = new Map(mapRef.current as HTMLElement, { center: { lat: 20, lng: 0 }, zoom: 2, mapId: "STREET_VIEW_GALLERY_MAP", }); // Initialize Mini Map miniMapObj.current = new Map(miniMapRef.current as HTMLElement, { center: { lat: 20, lng: 0 }, zoom: 14, mapId: "STREET_VIEW_MINI_MAP", disableDefaultUI: true, // This hides everything (pegman, map types, etc.) zoomControl: false, mapTypeControl: false, streetViewControl: false, fullscreenControl: false, clickableIcons: false, keyboardShortcuts: false }); miniMapMarkerObj.current = new AdvancedMarkerElement({ map: miniMapObj.current, position: { lat: 0, lng: 0 }, }); // Intercept Pegman Drops const defaultStreetView = googleMapObj.current.getStreetView(); // We must hide the close button since we are intercepting anyway, but just in case defaultStreetView.setOptions({ enableCloseButton: false }); defaultStreetView.addListener("visible_changed", () => { if (defaultStreetView.getVisible()) { const pos = defaultStreetView.getPosition(); // Intercept the drop: immediately hide the buggy WebGL canvas defaultStreetView.setVisible(false); if (pos) { // const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; setIframeUrl(`https://www.google.com/maps/embed/v1/streetview?key=${runtimeApiKey}&location=${pos.lat()},${pos.lng()}&heading=0&pitch=0&fov=90`); setViewingStreetView(true); if (miniMapObj.current && miniMapMarkerObj.current) { miniMapObj.current.setCenter(pos); miniMapMarkerObj.current.position = pos; } } } }); }); }, [runtimeApiKey]); // Draw all GPX routes for filtered locations useEffect(() => { if (!googleMapObj.current || typeof window === 'undefined' || !window.google || !geometryLoaded) return; // Clear existing routes activePolylinesRef.current.forEach((p) => p.polyline.setMap(null)); activePolylinesRef.current = []; const bounds = new google.maps.LatLngBounds(); let hasPoints = false; let calculatedMeters = 0; const newThumbnails: Record = {}; filteredLocations.forEach((loc) => { if (!loc.gpxData) return; const parser = new DOMParser(); const xmlDoc = parser.parseFromString(loc.gpxData, "text/xml"); const geoJson = gpx(xmlDoc); let coords: google.maps.LatLngLiteral[] = []; let locBounds = new google.maps.LatLngBounds(); geoJson.features.forEach((feature) => { if (feature.geometry?.type === "LineString") { coords = coords.concat(feature.geometry.coordinates.map((c: any) => ({ lat: c[1], lng: c[0] }))); } else if (feature.geometry?.type === "MultiLineString") { feature.geometry.coordinates.forEach((line: any) => { coords = coords.concat(line.map((c: any) => ({ lat: c[1], lng: c[0] }))); }); } else if (feature.geometry?.type === "Point") { coords.push({ lat: feature.geometry.coordinates[1], lng: feature.geometry.coordinates[0] }); } }); if (coords.length > 0) { // Calculate distance calculatedMeters += google.maps.geometry.spherical.computeLength(coords); const polyline = new google.maps.Polyline({ path: coords, strokeColor: "#0000FF", strokeOpacity: 0.6, strokeWeight: 4, clickable: true, map: googleMapObj.current, zIndex: selectedLocation?.id === loc.id ? 10 : 1, // Elevate selected outline }); // Also draw on mini map const miniPolyline = new google.maps.Polyline({ path: coords, strokeColor: "#0000FF", strokeOpacity: 0.8, strokeWeight: 4, map: miniMapObj.current, zIndex: 1, clickable: true }); coords.forEach((c) => { bounds.extend(c); locBounds.extend(c); hasPoints = true; }); activePolylinesRef.current.push({ id: loc.id, polyline, bounds: locBounds, firstCoord: coords[0] }); // Add Google Street View click detection (Main Map) polyline.addListener("click", (clickEvent: google.maps.MapMouseEvent) => { if (!clickEvent.latLng) return; const clickPos = clickEvent.latLng; // const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; setIframeUrl(`https://www.google.com/maps/embed/v1/streetview?key=${runtimeApiKey}&location=${clickPos.lat()},${clickPos.lng()}&heading=0&pitch=0&fov=90`); setViewingStreetView(true); if (miniMapObj.current && miniMapMarkerObj.current) { miniMapObj.current.setCenter(clickPos); miniMapMarkerObj.current.position = clickPos; } }); // Add Google Street View click detection (Mini Map) miniPolyline.addListener("click", (clickEvent: google.maps.MapMouseEvent) => { if (!clickEvent.latLng) return; const clickPos = clickEvent.latLng; // const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; setIframeUrl(`https://www.google.com/maps/embed/v1/streetview?key=${runtimeApiKey}&location=${clickPos.lat()},${clickPos.lng()}&heading=0&pitch=0&fov=90`); setViewingStreetView(true); if (miniMapObj.current && miniMapMarkerObj.current) { miniMapObj.current.setCenter(clickPos); miniMapMarkerObj.current.position = clickPos; } }); // Generate Static Map Thumbnail URL try { const maxThumbnailPoints = 150; const step = Math.max(1, Math.floor(coords.length / maxThumbnailPoints)); const simplifiedCoords = coords.filter((_, i) => i % step === 0); const encodedPath = google.maps.geometry.encoding.encodePath(simplifiedCoords); // const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; // Requesting a high-res (scale=2) map, and a slightly taller size so the Google logo sits proportionally smaller at the bottom right. newThumbnails[loc.id] = `https://maps.googleapis.com/maps/api/staticmap?size=600x250&scale=2&path=weight:3%7Ccolor:blue%7Cenc:${encodedPath}&key=${runtimeApiKey}`; } catch (err) { console.error("Failed to generate static map thumbnail", err); } } }); setThumbnails(newThumbnails); // Update stats setTotalMiles(calculatedMeters * 0.000621371); // Fit bounds for all visible filtered locations if (hasPoints) { googleMapObj.current.fitBounds(bounds); } }, [filteredLocations, geometryLoaded, selectedLocation]); // Note: dependency on selectedLocation is REMOVED to prevent redrawing all routes! // Handle Selection Highlights and panning smoothly useEffect(() => { if (!googleMapObj.current || !geometryLoaded) return; let didPan = false; activePolylinesRef.current.forEach((item) => { if (selectedLocation?.id === item.id) { item.polyline.setOptions({ strokeColor: "#00FF00", strokeOpacity: 1.0, strokeWeight: 6, zIndex: 10 }); // Smoothly fit bounds exactly like the initial filtered view if (!didPan) { googleMapObj.current!.fitBounds(item.bounds); didPan = true; } } else { item.polyline.setOptions({ strokeColor: "#0000FF", strokeOpacity: 0.6, strokeWeight: 4, zIndex: 1 }); } }); }, [selectedLocation, geometryLoaded]); // Update URL when selection changes useEffect(() => { if (!selectedLocation) return; if (typeof window !== "undefined") { const url = new URL(window.location.href); url.searchParams.set("locationId", selectedLocation.id); window.history.pushState({}, "", url.toString()); } }, [selectedLocation]); const closeMobileSidebar = () => { setIsMobileSidebarOpen(false); }; const handleLocationSelect = (loc: Location) => { // Switch to map view to show route, not street view directly setViewingStreetView(false); setIframeUrl(null); // If clicking the currently selected location, force a map pan/zoom because React state won't trigger the effect if (selectedLocation?.id === loc.id) { const item = activePolylinesRef.current.find(p => p.id === loc.id); if (item && googleMapObj.current) { googleMapObj.current.fitBounds(item.bounds); } } else { setSelectedLocation(loc); } // Auto-close standard Bootstrap offcanvas on mobile natively closeMobileSidebar(); }; return (
{/* Left: List */}
Locations
{filteredLocations.map((loc) => (
handleLocationSelect(loc)} style={{ cursor: "pointer", overflow: "hidden" }} > {/* --- MOBILE LAYOUT (Horizontal) --- */}
{/* Thumbnail Map (Left) */}
{thumbnails[loc.id] ? ( Route thumbnail ) : (
Loading...
)}
{/* Content (Right) */}
{loc.title}

{loc.city}
{new Date(loc.captureDate).toLocaleDateString()}

{loc.tags && ( {loc.tags} )} {!loc.gpxData && ( No Route )}
{/* --- DESKTOP LAYOUT (Vertical/Original) --- */}
{/* Thumbnail Map (Top) */}
{thumbnails[loc.id] ? ( Route thumbnail ) : ( Loading Map... )}
{/* Content (Bottom) */}
{loc.title}

{loc.city} • {new Date(loc.captureDate).toLocaleDateString()}

{loc.tags}
{!loc.gpxData && ( No Route Data )}
))} {filteredLocations.length === 0 && (
No locations found.
)} {/* Oldest date indicator at the bottom */} {locations.length > 0 && (
Images dating back to { new Date(Math.min(...locations.map(l => new Date(l.captureDate).getTime()))).getFullYear() }
)}
{/* Main Panel (Map or Street View) */}
{/* Overlay controls and mini-map, only shown when in Street View mode */} {viewingStreetView && iframeUrl && ( <>
)}
{/* Manual React Backdrop for Mobile Sidebar */} {isMobileSidebarOpen && (
setIsMobileSidebarOpen(false)} /> )}
); }