Add admin panel, gallery, and security hardening.
Replace forgeable cookie auth with signed JWT sessions, protect admin APIs, add input validation, and improve Docker deployment config.
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
"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<Location[]>([]);
|
||||
const [filteredLocations, setFilteredLocations] = useState<Location[]>([]);
|
||||
const [selectedLocation, setSelectedLocation] = useState<Location | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [viewingStreetView, setViewingStreetView] = useState(false);
|
||||
const [iframeUrl, setIframeUrl] = useState<string | null>(null);
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const [totalMiles, setTotalMiles] = useState<number>(0);
|
||||
const [geometryLoaded, setGeometryLoaded] = useState(false);
|
||||
const [runtimeApiKey, setRuntimeApiKey] = useState<string>("");
|
||||
|
||||
const locationsRef = useRef<Location[]>([]);
|
||||
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const miniMapRef = useRef<HTMLDivElement>(null);
|
||||
const googleMapObj = useRef<google.maps.Map | null>(null);
|
||||
const miniMapObj = useRef<google.maps.Map | null>(null);
|
||||
const miniMapMarkerObj = useRef<google.maps.marker.AdvancedMarkerElement | null>(null);
|
||||
const svServiceRef = useRef<google.maps.StreetViewService | null>(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<string, string> = {};
|
||||
|
||||
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 (
|
||||
<div className="container-fluid vh-100 d-flex flex-column p-0 overflow-hidden gsv-background">
|
||||
<nav className="navbar navbar-dark gsv-navbar px-2 px-md-3 mt-0 d-flex justify-content-between align-items-center z-3">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<button
|
||||
className="btn btn-outline-light btn-sm d-md-none"
|
||||
type="button"
|
||||
onClick={() => setIsMobileSidebarOpen(true)}
|
||||
aria-controls="gallerySidebar"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<span className="navbar-brand mb-0 h1 gsv-title fs-md-4 d-none d-lg-block">Street View Gallery</span>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex-grow-1 px-2 px-md-4 gsv-search-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm gsv-input bg-dark text-white border-secondary w-100"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-white fw-medium d-flex align-items-center gap-2 flex-shrink-0" style={{ fontSize: "0.85rem", whiteSpace: "nowrap" }}>
|
||||
{totalMiles > 0 && <span>📍 {totalMiles.toFixed(1)} miles tracked</span>}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="row flex-grow-1 g-0 position-relative" style={{ minHeight: 0 }}>
|
||||
{/* Left: List */}
|
||||
<div
|
||||
className={`col-md-3 offcanvas-md offcanvas-start gsv-sidebar d-flex flex-column bg-dark h-100 border-end border-secondary ${isMobileSidebarOpen ? 'show' : ''}`}
|
||||
tabIndex={-1}
|
||||
id="gallerySidebar"
|
||||
aria-labelledby="gallerySidebarLabel"
|
||||
style={isMobileSidebarOpen ? { visibility: 'visible' } : undefined}
|
||||
>
|
||||
<div className="offcanvas-header d-md-none border-bottom border-secondary text-white p-3 d-flex justify-content-between align-items-center">
|
||||
<h5 className="offcanvas-title m-0" id="gallerySidebarLabel">Locations</h5>
|
||||
<button type="button" className="btn-close btn-close-white" onClick={closeMobileSidebar} aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div className="offcanvas-body p-0 d-flex flex-column flex-grow-1" style={{ minHeight: 0 }}>
|
||||
<div className="flex-grow-1 p-2" style={{ overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
{filteredLocations.map((loc) => (
|
||||
<div
|
||||
key={loc.id}
|
||||
className={`card mb-2 gsv-card cursor-pointer p-0 shadow-sm ${selectedLocation?.id === loc.id ? "gsv-card-active border-success border-2" : "border-secondary"
|
||||
}`}
|
||||
onClick={() => handleLocationSelect(loc)}
|
||||
style={{ cursor: "pointer", overflow: "hidden" }}
|
||||
>
|
||||
{/* --- MOBILE LAYOUT (Horizontal) --- */}
|
||||
<div className="row g-0 h-100 d-md-none">
|
||||
{/* Thumbnail Map (Left) */}
|
||||
<div className="col-5 bg-dark border-end border-secondary position-relative" style={{ minHeight: "105px", overflow: "hidden" }}>
|
||||
{thumbnails[loc.id] ? (
|
||||
<img
|
||||
src={thumbnails[loc.id]}
|
||||
alt="Route thumbnail"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
objectPosition: "center",
|
||||
transform: "scale(1.2) translateY(5%)", // Zoom in slightly and push down to crop out the bottom Google/Map Data logos
|
||||
transformOrigin: "center bottom"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-100 h-100 d-flex align-items-center justify-content-center position-absolute top-0 start-0">
|
||||
<span className="text-secondary" style={{ fontSize: "0.7rem" }}>Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Content (Right) */}
|
||||
<div className="col-7 bg-dark d-flex flex-column justify-content-center p-2 text-white">
|
||||
<h6 className="card-title mb-1 text-truncate gsv-title" style={{ fontSize: "0.9rem" }} title={loc.title}>{loc.title}</h6>
|
||||
<p className="card-text mb-1 text-truncate gsv-subtitle" style={{ fontSize: "0.75rem", lineHeight: "1.2" }}>
|
||||
{loc.city}<br />{new Date(loc.captureDate).toLocaleDateString()}
|
||||
</p>
|
||||
<div className="d-flex justify-content-between align-items-center mt-auto">
|
||||
{loc.tags && (
|
||||
<small className="text-secondary text-truncate" style={{ maxWidth: "100%", fontSize: "0.7rem", display: "block" }}>{loc.tags}</small>
|
||||
)}
|
||||
{!loc.gpxData && (
|
||||
<span className="badge bg-secondary" style={{ fontSize: "0.55rem" }}>No Route</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- DESKTOP LAYOUT (Vertical/Original) --- */}
|
||||
<div className="d-none d-md-block">
|
||||
{/* Thumbnail Map (Top) */}
|
||||
<div className="card-img-top bg-dark border-bottom border-secondary d-flex align-items-center justify-content-center" style={{ width: "100%", height: "140px", flexShrink: 0, overflow: "hidden", position: "relative" }}>
|
||||
{thumbnails[loc.id] ? (
|
||||
<img
|
||||
src={thumbnails[loc.id]}
|
||||
alt="Route thumbnail"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
objectPosition: "center",
|
||||
transform: "scale(1.2) translateY(5%)",
|
||||
transformOrigin: "center bottom"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-secondary small">Loading Map...</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Content (Bottom) */}
|
||||
<div className="card-body p-2 bg-dark text-white">
|
||||
<h6 className="card-title mb-1 text-truncate gsv-title" title={loc.title}>{loc.title}</h6>
|
||||
<p className="card-text small mb-1 text-truncate gsv-subtitle">
|
||||
{loc.city} • {new Date(loc.captureDate).toLocaleDateString()}
|
||||
</p>
|
||||
<div className="d-flex justify-content-between align-items-center mt-1">
|
||||
<small className="text-secondary text-truncate" style={{ maxWidth: "100%" }}>{loc.tags}</small>
|
||||
</div>
|
||||
<div className="d-flex justify-content-between align-items-center mt-1">
|
||||
{!loc.gpxData && (
|
||||
<span className="badge bg-secondary" style={{ fontSize: "0.6rem" }}>No Route Data</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredLocations.length === 0 && (
|
||||
<div className="text-center text-muted mt-4 p-3 bg-dark rounded">No locations found.</div>
|
||||
)}
|
||||
|
||||
{/* Oldest date indicator at the bottom */}
|
||||
{locations.length > 0 && (
|
||||
<div className="text-secondary mt-3 mb-2" style={{ fontSize: "0.75rem", textAlign: "center", textTransform: "uppercase", letterSpacing: "1px" }}>
|
||||
Images dating back to {
|
||||
new Date(Math.min(...locations.map(l => new Date(l.captureDate).getTime()))).getFullYear()
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Main Panel (Map or Street View) */}
|
||||
<div className="col-12 col-md-9 position-relative bg-black d-flex flex-column p-0">
|
||||
<div ref={mapRef} style={{ width: "100%", height: "100%", position: "absolute", zIndex: 0 }} />
|
||||
|
||||
{/* Overlay controls and mini-map, only shown when in Street View mode */}
|
||||
{viewingStreetView && iframeUrl && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 1,
|
||||
backgroundColor: '#000'
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
loading="lazy"
|
||||
allowFullScreen
|
||||
src={iframeUrl}
|
||||
></iframe>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-dark position-absolute shadow-lg"
|
||||
style={{ top: "60px", left: "10px", zIndex: 2, display: "flex", alignItems: "center", gap: "8px" }}
|
||||
onClick={() => {
|
||||
setViewingStreetView(false);
|
||||
setIframeUrl(null);
|
||||
}}
|
||||
>
|
||||
<span>←</span> Back to Map
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={miniMapRef}
|
||||
className={`shadow-lg border border-3 border-dark rounded ${viewingStreetView ? 'd-block' : 'd-none'}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "20px",
|
||||
left: "20px",
|
||||
width: "200px",
|
||||
height: "200px",
|
||||
zIndex: 4,
|
||||
pointerEvents: "auto"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual React Backdrop for Mobile Sidebar */}
|
||||
{isMobileSidebarOpen && (
|
||||
<div
|
||||
className="offcanvas-backdrop fade show d-md-none"
|
||||
onClick={() => setIsMobileSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user