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:
Atakan Doğan Özban
2026-07-12 00:34:46 +02:00
parent 91dbfa7d77
commit d74c422de6
31 changed files with 2749 additions and 153 deletions
+545
View File
@@ -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>&larr;</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>
);
}