952 lines
30 KiB
JavaScript
952 lines
30 KiB
JavaScript
"use client";
|
||
|
||
import React, { useEffect, useState, Suspense } from "react";
|
||
import Link from "next/link";
|
||
import dynamic from "next/dynamic";
|
||
import { useSearchParams, useRouter } from "next/navigation";
|
||
import { useTranslation } from "@/lib/i18n";
|
||
import Button from "@/components/ui/Button";
|
||
import { mapLayers } from "@/components/ui/mapLayers";
|
||
import { formatProjectStatus } from "@/lib/utils";
|
||
|
||
function ProjectsMapPageContent() {
|
||
const searchParams = useSearchParams();
|
||
const router = useRouter();
|
||
const { t } = useTranslation();
|
||
const [projects, setProjects] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [mapCenter, setMapCenter] = useState([50.0614, 19.9366]); // Default to Krakow, Poland
|
||
const [mapZoom, setMapZoom] = useState(10); // Default zoom level
|
||
const [statusFilters, setStatusFilters] = useState({
|
||
registered: true,
|
||
in_progress_design: true,
|
||
in_progress_construction: true,
|
||
fulfilled: true,
|
||
cancelled: true,
|
||
});
|
||
const [activeBaseLayer, setActiveBaseLayer] = useState("OpenStreetMap");
|
||
const [activeOverlays, setActiveOverlays] = useState([]);
|
||
const [showLayerPanel, setShowLayerPanel] = useState(true);
|
||
const [currentTool, setCurrentTool] = useState("move"); // Current map tool
|
||
|
||
// Dynamically import the map component to avoid SSR issues
|
||
const DynamicMap = dynamic(() => import("@/components/ui/LeafletMap"), {
|
||
ssr: false,
|
||
loading: () => (
|
||
<div className="w-full h-96 bg-gray-100 animate-pulse rounded-lg flex items-center justify-center">
|
||
<span className="text-gray-500">{t('map.loadingMap')}</span>
|
||
</div>
|
||
),
|
||
});
|
||
|
||
// Status configuration with colors and labels
|
||
const statusConfig = {
|
||
registered: {
|
||
color: "#6B7280",
|
||
label: formatProjectStatus("registered"),
|
||
shortLabel: "Zarejestr.",
|
||
},
|
||
in_progress_design: {
|
||
color: "#3B82F6",
|
||
label: formatProjectStatus("in_progress_design"),
|
||
shortLabel: "W real. (P)",
|
||
},
|
||
in_progress_construction: {
|
||
color: "#F59E0B",
|
||
label: formatProjectStatus("in_progress_construction"),
|
||
shortLabel: "W real. (R)",
|
||
},
|
||
fulfilled: {
|
||
color: "#10B981",
|
||
label: formatProjectStatus("fulfilled"),
|
||
shortLabel: "Zakończony",
|
||
},
|
||
cancelled: {
|
||
color: "#EF4444",
|
||
label: formatProjectStatus("cancelled"),
|
||
shortLabel: "Wycofany",
|
||
},
|
||
};
|
||
|
||
// Toggle all status filters
|
||
const toggleAllFilters = () => {
|
||
const allActive = Object.values(statusFilters).every((value) => value);
|
||
const newState = allActive
|
||
? Object.keys(statusFilters).reduce(
|
||
(acc, key) => ({ ...acc, [key]: false }),
|
||
{}
|
||
)
|
||
: Object.keys(statusFilters).reduce(
|
||
(acc, key) => ({ ...acc, [key]: true }),
|
||
{}
|
||
);
|
||
setStatusFilters(newState);
|
||
};
|
||
|
||
// Toggle status filter
|
||
const toggleStatusFilter = (status) => {
|
||
setStatusFilters((prev) => ({
|
||
...prev,
|
||
[status]: !prev[status],
|
||
}));
|
||
};
|
||
|
||
// Layer control functions
|
||
const handleBaseLayerChange = (layerName) => {
|
||
setActiveBaseLayer(layerName);
|
||
};
|
||
|
||
const toggleOverlay = (layerName) => {
|
||
setActiveOverlays((prev) => {
|
||
if (prev.includes(layerName)) {
|
||
return prev.filter((name) => name !== layerName);
|
||
} else {
|
||
return [...prev, layerName];
|
||
}
|
||
});
|
||
};
|
||
|
||
const toggleLayerPanel = () => {
|
||
setShowLayerPanel(!showLayerPanel);
|
||
};
|
||
|
||
// Update URL with current map state (debounced to avoid too many updates)
|
||
const updateURL = (center, zoom) => {
|
||
const params = new URLSearchParams();
|
||
params.set("lat", center[0].toFixed(6));
|
||
params.set("lng", center[1].toFixed(6));
|
||
params.set("zoom", zoom.toString());
|
||
|
||
// Use replace to avoid cluttering browser history
|
||
router.replace(`/projects/map?${params.toString()}`, { scroll: false });
|
||
};
|
||
|
||
// Handle map view changes with debouncing
|
||
const handleMapViewChange = (center, zoom) => {
|
||
setMapCenter(center);
|
||
setMapZoom(zoom);
|
||
|
||
// Debounce URL updates to avoid too many history entries
|
||
clearTimeout(window.mapUpdateTimeout);
|
||
window.mapUpdateTimeout = setTimeout(() => {
|
||
updateURL(center, zoom);
|
||
}, 500); // Wait 500ms after the last move to update URL
|
||
};
|
||
|
||
// Hide navigation and ensure full-screen layout
|
||
useEffect(() => {
|
||
// Check for URL parameters for coordinates and zoom
|
||
const lat = searchParams.get("lat");
|
||
const lng = searchParams.get("lng");
|
||
const zoom = searchParams.get("zoom");
|
||
|
||
if (lat && lng) {
|
||
const latitude = parseFloat(lat);
|
||
const longitude = parseFloat(lng);
|
||
if (!isNaN(latitude) && !isNaN(longitude)) {
|
||
setMapCenter([latitude, longitude]);
|
||
}
|
||
}
|
||
|
||
if (zoom) {
|
||
const zoomLevel = parseInt(zoom);
|
||
if (!isNaN(zoomLevel) && zoomLevel >= 1 && zoomLevel <= 20) {
|
||
setMapZoom(zoomLevel);
|
||
}
|
||
}
|
||
|
||
// Hide navigation bar for full-screen experience
|
||
const nav = document.querySelector("nav");
|
||
if (nav) {
|
||
nav.style.display = "none";
|
||
}
|
||
|
||
// Prevent scrolling on body
|
||
document.body.style.overflow = "hidden";
|
||
document.documentElement.style.overflow = "hidden";
|
||
|
||
// Cleanup when leaving page
|
||
return () => {
|
||
if (nav) {
|
||
nav.style.display = "";
|
||
}
|
||
document.body.style.overflow = "";
|
||
document.documentElement.style.overflow = "";
|
||
|
||
// Clear any pending URL updates
|
||
if (window.mapUpdateTimeout) {
|
||
clearTimeout(window.mapUpdateTimeout);
|
||
}
|
||
};
|
||
}, [searchParams]);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/projects")
|
||
.then((res) => res.json())
|
||
.then((data) => {
|
||
setProjects(data);
|
||
|
||
// Only calculate center based on projects if no URL parameters are provided
|
||
const lat = searchParams.get("lat");
|
||
const lng = searchParams.get("lng");
|
||
|
||
if (!lat || !lng) {
|
||
// Calculate center based on projects with coordinates
|
||
const projectsWithCoords = data.filter((p) => p.coordinates);
|
||
if (projectsWithCoords.length > 0) {
|
||
const avgLat =
|
||
projectsWithCoords.reduce((sum, p) => {
|
||
const [lat] = p.coordinates
|
||
.split(",")
|
||
.map((coord) => parseFloat(coord.trim()));
|
||
return sum + lat;
|
||
}, 0) / projectsWithCoords.length;
|
||
|
||
const avgLng =
|
||
projectsWithCoords.reduce((sum, p) => {
|
||
const [, lng] = p.coordinates
|
||
.split(",")
|
||
.map((coord) => parseFloat(coord.trim()));
|
||
return sum + lng;
|
||
}, 0) / projectsWithCoords.length;
|
||
|
||
setMapCenter([avgLat, avgLng]);
|
||
}
|
||
}
|
||
|
||
setLoading(false);
|
||
})
|
||
.catch((error) => {
|
||
console.error("Error fetching projects:", error);
|
||
setLoading(false);
|
||
});
|
||
}, [searchParams]);
|
||
|
||
// Convert projects to map markers with filtering
|
||
const markers = projects
|
||
.filter((project) => project.coordinates)
|
||
.filter((project) => statusFilters[project.project_status] !== false)
|
||
.map((project) => {
|
||
const [lat, lng] = project.coordinates
|
||
.split(",")
|
||
.map((coord) => parseFloat(coord.trim()));
|
||
if (isNaN(lat) || isNaN(lng)) {
|
||
return null;
|
||
}
|
||
|
||
const statusInfo =
|
||
statusConfig[project.project_status] || statusConfig.registered;
|
||
|
||
return {
|
||
position: [lat, lng],
|
||
color: statusInfo.color,
|
||
popup: (
|
||
<div className="min-w-72 max-w-80">
|
||
<div className="mb-3 pb-2 border-b border-gray-200">
|
||
<h3 className="font-semibold text-base mb-1 text-gray-900">
|
||
{project.project_name}
|
||
</h3>
|
||
{project.project_number && (
|
||
<div className="inline-block bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">
|
||
{project.project_number}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2 text-sm text-gray-600 mb-3">
|
||
{project.address && (
|
||
<div className="flex items-start gap-2">
|
||
<svg
|
||
className="w-4 h-4 mt-0.5 text-gray-400 flex-shrink-0"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||
/>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||
/>
|
||
</svg>
|
||
<div>
|
||
<span className="font-medium text-gray-700">
|
||
{project.address}
|
||
</span>
|
||
{project.city && (
|
||
<span className="text-gray-500">, {project.city}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{project.wp && (
|
||
<div>
|
||
<span className="font-medium text-gray-700">WP:</span>{" "}
|
||
{project.wp}
|
||
</div>
|
||
)}
|
||
{project.plot && (
|
||
<div>
|
||
<span className="font-medium text-gray-700">Plot:</span>{" "}
|
||
{project.plot}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{project.project_status && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-gray-700">Status:</span>
|
||
<span
|
||
className="inline-block px-2 py-1 rounded-full text-xs font-medium text-white"
|
||
style={{ backgroundColor: statusInfo.color }}
|
||
>
|
||
{statusInfo.shortLabel}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="pt-2 border-t border-gray-200">
|
||
<Link href={`/projects/${project.project_id}`}>
|
||
<Button variant="primary" size="sm" className="w-full">
|
||
<svg
|
||
className="w-4 h-4 mr-2"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||
/>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||
/>
|
||
</svg>
|
||
{t('map.viewProjectDetails')}
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
),
|
||
};
|
||
})
|
||
.filter((marker) => marker !== null);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="fixed inset-0 bg-gray-50 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="w-12 h-12 mx-auto mb-4 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin"></div>
|
||
<p className="text-gray-600 font-medium">{t('map.loadingProjectsMap')}</p>
|
||
<p className="text-sm text-gray-500 mt-2">
|
||
{t('map.preparingMap')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="fixed inset-0 bg-gray-50 overflow-hidden">
|
||
{/* Floating Header - Left Side */}
|
||
<div className="absolute top-4 left-4 z-[1000]">
|
||
{/* Title Box */}
|
||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
||
<div className="flex items-center gap-3">
|
||
<h1 className="text-lg font-semibold text-gray-900">
|
||
{t('map.projectsMap')}
|
||
</h1>
|
||
<div className="text-sm text-gray-600">
|
||
{markers.length} of {projects.length} {t('map.projectsWithCoordinates')}
|
||
</div>
|
||
</div>{" "}
|
||
</div>
|
||
</div>
|
||
{/* Zoom Controls - Below Title */}
|
||
<div className="absolute top-20 left-4 z-[1000]">
|
||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 flex flex-col">
|
||
<button
|
||
className="px-3 py-2 hover:bg-gray-50 transition-colors duration-200 border-b border-gray-200 text-gray-700 font-medium text-lg"
|
||
onClick={() => {
|
||
// This will be handled by the map component
|
||
const event = new CustomEvent("mapZoomIn");
|
||
window.dispatchEvent(event);
|
||
}}
|
||
title={t('map.zoomIn')}
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
className="px-3 py-2 hover:bg-gray-50 transition-colors duration-200 text-gray-700 font-medium text-lg"
|
||
onClick={() => {
|
||
// This will be handled by the map component
|
||
const event = new CustomEvent("mapZoomOut");
|
||
window.dispatchEvent(event);
|
||
}}
|
||
title={t('map.zoomOut')}
|
||
>
|
||
−
|
||
</button>{" "}
|
||
</div>
|
||
</div>{" "}
|
||
{/* Tool Panel - Below Zoom Controls */}
|
||
<div className="absolute top-48 left-4 z-[1000]">
|
||
{" "}
|
||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 flex flex-col">
|
||
{" "}
|
||
{/* Move Tool */}
|
||
<button
|
||
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||
currentTool === "move"
|
||
? "bg-blue-100 text-blue-700"
|
||
: "text-gray-700 hover:bg-gray-50"
|
||
}`}
|
||
onClick={() => setCurrentTool("move")}
|
||
title={t('map.moveTool')}
|
||
>
|
||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 512 512">
|
||
<path d="M256 0c-25.3 0-47.2 14.7-57.6 36c-7-2.6-14.5-4-22.4-4c-35.3 0-64 28.7-64 64l0 165.5-2.7-2.7c-25-25-65.5-25-90.5 0s-25 65.5 0 90.5L106.5 437c48 48 113.1 75 181 75l8.5 0 8 0c1.5 0 3-.1 4.5-.4c91.7-6.2 165-79.4 171.1-171.1c.3-1.5 .4-3 .4-4.5l0-176c0-35.3-28.7-64-64-64c-5.5 0-10.9 .7-16 2l0-2c0-35.3-28.7-64-64-64c-7.9 0-15.4 1.4-22.4 4C303.2 14.7 281.3 0 256 0zM240 96.1l0-.1 0-32c0-8.8 7.2-16 16-16s16 7.2 16 16l0 31.9 0 .1 0 136c0 13.3 10.7 24 24 24s24-10.7 24-24l0-136c0 0 0 0 0-.1c0-8.8 7.2-16 16-16s16 7.2 16 16l0 55.9c0 0 0 .1 0 .1l0 80c0 13.3 10.7 24 24 24s24-10.7 24-24l0-71.9c0 0 0-.1 0-.1c0-8.8 7.2-16 16-16s16 7.2 16 16l0 172.9c-.1 .6-.1 1.3-.2 1.9c-3.4 69.7-59.3 125.6-129 129c-.6 0-1.3 .1-1.9 .2l-4.9 0-8.5 0c-55.2 0-108.1-21.9-147.1-60.9L52.7 315.3c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0L119 336.4c6.9 6.9 17.2 8.9 26.2 5.2s14.8-12.5 14.8-22.2L160 96c0-8.8 7.2-16 16-16c8.8 0 16 7.1 16 15.9L192 232c0 13.3 10.7 24 24 24s24-10.7 24-24l0-135.9z" />
|
||
</svg>
|
||
</button>
|
||
{/* Select Tool */}
|
||
<button
|
||
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||
currentTool === "select"
|
||
? "bg-blue-100 text-blue-700"
|
||
: "text-gray-700 hover:bg-gray-50"
|
||
}`}
|
||
onClick={() => setCurrentTool("select")}
|
||
title={t('map.selectTool')}
|
||
>
|
||
<svg
|
||
className="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
{/* Measure Tool */}
|
||
<button
|
||
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||
currentTool === "measure"
|
||
? "bg-blue-100 text-blue-700"
|
||
: "text-gray-700 hover:bg-gray-50"
|
||
}`}
|
||
onClick={() => setCurrentTool("measure")}
|
||
title={t('map.measureDistance')}
|
||
>
|
||
<svg
|
||
className="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M7 21l10-10M7 21H3v-4l10-10 4 4M7 21l4-4M17 7l4-4M17 7l-4-4M17 7l-4 4"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
{/* Draw Tool */}
|
||
<button
|
||
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||
currentTool === "draw"
|
||
? "bg-blue-100 text-blue-700"
|
||
: "text-gray-700 hover:bg-gray-50"
|
||
}`}
|
||
onClick={() => setCurrentTool("draw")}
|
||
title={t('map.drawMarkup')}
|
||
>
|
||
<svg
|
||
className="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
{/* Pin/Marker Tool */}
|
||
<button
|
||
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||
currentTool === "pin"
|
||
? "bg-blue-100 text-blue-700"
|
||
: "text-gray-700 hover:bg-gray-50"
|
||
}`}
|
||
onClick={() => setCurrentTool("pin")}
|
||
title={t('map.addPinMarker')}
|
||
>
|
||
<svg
|
||
className="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||
/>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
{/* Area Tool */}
|
||
<button
|
||
className={`p-3 transition-colors duration-200 ${
|
||
currentTool === "area"
|
||
? "bg-blue-100 text-blue-700"
|
||
: "text-gray-700 hover:bg-gray-50"
|
||
}`}
|
||
onClick={() => setCurrentTool("area")}
|
||
title={t('map.measureArea')}
|
||
>
|
||
<svg
|
||
className="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M4 8V6a2 2 0 012-2h2M4 16v2a2 2 0 002 2h2m8-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/* Layer Control Panel - Right Side */}
|
||
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-3">
|
||
{/* Action Buttons */}
|
||
<div className="flex gap-2 justify-end">
|
||
<Link href="/projects">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="bg-white/95 backdrop-blur-sm border-gray-200 shadow-lg hover:bg-white"
|
||
>
|
||
<svg
|
||
className="w-4 h-4 mr-2"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||
/>
|
||
</svg>
|
||
{t('map.listView')}
|
||
</Button>
|
||
</Link>
|
||
<Link href="/projects/new">
|
||
<Button variant="primary" size="sm" className="shadow-lg">
|
||
<svg
|
||
className="w-4 h-4 mr-2"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M12 4v16m8-8H4"
|
||
/>
|
||
</svg>
|
||
{t('map.addProject')}
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
|
||
{/* Layer Control Panel */}
|
||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 layer-panel-container">
|
||
{/* Layer Control Header */}
|
||
<div className="px-4 py-3 border-b border-gray-200">
|
||
<button
|
||
onClick={toggleLayerPanel}
|
||
className="flex items-center justify-between w-full text-left layer-toggle-button"
|
||
title={t('map.toggleLayerControls')}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<svg
|
||
className="w-4 h-4 text-gray-600"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||
/>
|
||
</svg>
|
||
<span className="text-sm font-medium text-gray-700">
|
||
{t('map.mapLayers')}
|
||
</span>
|
||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">
|
||
{1 + activeOverlays.length} {t('map.active')}
|
||
</span>
|
||
</div>
|
||
<svg
|
||
className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${
|
||
showLayerPanel ? "rotate-180" : ""
|
||
}`}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M19 9l-7 7-7-7"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>{" "}
|
||
{/* Layer Control Content */}
|
||
<div
|
||
className={`transition-all duration-300 ease-in-out ${
|
||
showLayerPanel
|
||
? "max-h-[70vh] opacity-100 overflow-visible"
|
||
: "max-h-0 opacity-0 overflow-hidden"
|
||
}`}
|
||
>
|
||
<div className="p-4 min-w-80 max-w-96 max-h-[60vh] overflow-y-auto">
|
||
{/* Base Layers Section */}
|
||
<div className="mb-4">
|
||
<h3 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||
/>
|
||
</svg>
|
||
{t('map.baseMaps')}
|
||
</h3>
|
||
<div className="space-y-2">
|
||
{mapLayers.base.map((layer, index) => (
|
||
<label
|
||
key={index}
|
||
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 cursor-pointer transition-colors duration-200"
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="baseLayer"
|
||
checked={activeBaseLayer === layer.name}
|
||
onChange={() => handleBaseLayerChange(layer.name)}
|
||
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||
/>
|
||
<span className="text-sm text-gray-700 flex-1">
|
||
{layer.name}
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Overlay Layers Section */}
|
||
{mapLayers.overlays && mapLayers.overlays.length > 0 && (
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
|
||
/>
|
||
</svg>
|
||
{t('map.overlayLayers')}
|
||
</h3>{" "}
|
||
<div className="space-y-2">
|
||
{mapLayers.overlays.map((layer, index) => (
|
||
<label
|
||
key={index}
|
||
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 cursor-pointer transition-colors duration-200"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={activeOverlays.includes(layer.name)}
|
||
onChange={() => toggleOverlay(layer.name)}
|
||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||
/>
|
||
<span className="text-sm text-gray-700 flex-1">
|
||
{layer.name}
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>{" "}
|
||
</div>
|
||
</div>
|
||
{/* Status Filter Panel - Bottom Left */}
|
||
<div className="absolute bottom-4 left-4 z-[1000]">
|
||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-medium text-gray-700 mr-2">
|
||
{t('map.filters')}
|
||
</span>
|
||
{/* Toggle All Button */}
|
||
<button
|
||
onClick={toggleAllFilters}
|
||
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 hover:bg-gray-200 transition-colors duration-200 mr-2"
|
||
title="Toggle all filters"
|
||
>
|
||
<svg
|
||
className="w-3 h-3"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M4 6h16M4 12h16M4 18h16"
|
||
/>
|
||
</svg>
|
||
<span className="text-gray-600">
|
||
{Object.values(statusFilters).every((v) => v)
|
||
? "Hide All"
|
||
: "Show All"}
|
||
</span>
|
||
</button>
|
||
{/* Individual Status Filters */}
|
||
{Object.entries(statusConfig).map(([status, config]) => {
|
||
const isActive = statusFilters[status];
|
||
const projectCount = projects.filter(
|
||
(p) => p.project_status === status && p.coordinates
|
||
).length;
|
||
|
||
return (
|
||
<button
|
||
key={status}
|
||
onClick={() => toggleStatusFilter(status)}
|
||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all duration-200 hover:bg-gray-100 ${
|
||
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
|
||
}`}
|
||
title={`Toggle ${config.label} (${projectCount} ${t('map.projects')})`}
|
||
>
|
||
<div
|
||
className={`w-3 h-3 rounded-full border-2 transition-all duration-200 ${
|
||
isActive ? "border-white shadow-sm" : "border-gray-300"
|
||
}`}
|
||
style={{
|
||
backgroundColor: isActive ? config.color : "#e5e7eb",
|
||
}}
|
||
></div>
|
||
<span
|
||
className={`transition-colors duration-200 ${
|
||
isActive ? "text-gray-700" : "text-gray-400"
|
||
}`}
|
||
>
|
||
{config.shortLabel}
|
||
</span>
|
||
<span
|
||
className={`ml-1 text-xs transition-colors duration-200 ${
|
||
isActive ? "text-gray-500" : "text-gray-300"
|
||
}`}
|
||
>
|
||
({projectCount})
|
||
</span>
|
||
</button>
|
||
);
|
||
})}{" "}
|
||
</div>
|
||
</div>
|
||
</div>{" "}
|
||
{/* Status Panel - Bottom Left */}
|
||
{markers.length > 0 && (
|
||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-medium text-gray-700 mr-2">
|
||
{t('map.filters')}
|
||
</span>
|
||
|
||
{/* Toggle All Button */}
|
||
<button
|
||
onClick={toggleAllFilters}
|
||
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 hover:bg-gray-200 transition-colors duration-200 mr-2"
|
||
title="Toggle all filters"
|
||
>
|
||
<svg
|
||
className="w-3 h-3"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M4 6h16M4 12h16M4 18h16"
|
||
/>
|
||
</svg>
|
||
<span className="text-gray-600">
|
||
{Object.values(statusFilters).every((v) => v)
|
||
? t('map.hideAll')
|
||
: t('map.showAll')}
|
||
</span>
|
||
</button>
|
||
|
||
{/* Individual Status Filters */}
|
||
{Object.entries(statusConfig).map(([status, config]) => {
|
||
const isActive = statusFilters[status];
|
||
const projectCount = projects.filter(
|
||
(p) => p.project_status === status && p.coordinates
|
||
).length;
|
||
|
||
return (
|
||
<button
|
||
key={status}
|
||
onClick={() => toggleStatusFilter(status)}
|
||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all duration-200 hover:bg-gray-100 ${
|
||
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
|
||
}`}
|
||
title={`Toggle ${config.label} (${projectCount} ${t('map.projects')})`}
|
||
>
|
||
<div
|
||
className={`w-3 h-3 rounded-full border-2 transition-all duration-200 ${
|
||
isActive ? "border-white shadow-sm" : "border-gray-300"
|
||
}`}
|
||
style={{
|
||
backgroundColor: isActive ? config.color : "#e5e7eb",
|
||
}}
|
||
></div>
|
||
<span
|
||
className={`transition-colors duration-200 ${
|
||
isActive ? "text-gray-700" : "text-gray-400"
|
||
}`}
|
||
>
|
||
{config.shortLabel}
|
||
</span>
|
||
<span
|
||
className={`ml-1 text-xs transition-colors duration-200 ${
|
||
isActive ? "text-gray-500" : "text-gray-300"
|
||
}`}
|
||
>
|
||
({projectCount})
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}{" "}
|
||
{/* Full Screen Map */}
|
||
{markers.length === 0 ? (
|
||
<div className="h-full w-full flex items-center justify-center bg-gray-100">
|
||
<div className="text-center max-w-md mx-auto p-8 bg-white rounded-lg shadow-lg">
|
||
<div className="text-gray-400 mb-4">
|
||
<svg
|
||
className="w-16 h-16 mx-auto"
|
||
fill="currentColor"
|
||
viewBox="0 0 20 20"
|
||
>
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"
|
||
clipRule="evenodd"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||
{t('map.noProjectsWithCoordinates')}
|
||
</h3>
|
||
<p className="text-gray-500 mb-6">
|
||
{t('map.noProjectsMessage')}
|
||
</p>
|
||
<div className="flex gap-3 justify-center">
|
||
<Link href="/projects">
|
||
<Button variant="outline">{t('map.viewAllProjects')}</Button>
|
||
</Link>
|
||
<Link href="/projects/new">
|
||
<Button variant="primary">{t('map.addProject')}</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="absolute inset-0">
|
||
<DynamicMap
|
||
center={mapCenter}
|
||
zoom={mapZoom}
|
||
markers={markers}
|
||
showLayerControl={false}
|
||
defaultLayer={activeBaseLayer}
|
||
activeOverlays={activeOverlays}
|
||
onViewChange={handleMapViewChange}
|
||
/>
|
||
</div>
|
||
)}{" "}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ProjectsMapPage() {
|
||
return (
|
||
<Suspense fallback={
|
||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||
<p className="text-gray-600">Loading map...</p>
|
||
</div>
|
||
</div>
|
||
}>
|
||
<ProjectsMapPageContent />
|
||
</Suspense>
|
||
);
|
||
}
|