Add dark map, marker editing and fullscreen
Add dark-theme styles and Leaflet z-index tweaks; switch tile layer URL/attribution based on Next.js theme. Introduce theme hook, PocketBase helper import and tile constants; lower label zoom breakpoint. Replace ZoomWatcher with MapWatcher that invalidates map size when toggling fullscreen. Add fullscreen UI, edit mode toggle, draggable markers when editing, and saveSupplierLocation to persist coordinate updates to PocketBase with optimistic UI and toast feedback. Misc: simplify cluster marker popup handling, ensure popups close before cluster flyTo, and tweak cluster distance label to reflect current zoom.
This commit is contained in:
parent
a6ac88a06f
commit
83935c990b
2 changed files with 129 additions and 31 deletions
|
|
@ -184,3 +184,36 @@
|
|||
.supplier-map-label::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dark .supplier-map-label {
|
||||
background: rgba(15, 23, 42, 0.94);
|
||||
color: #f8fafc;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dark .leaflet-control-zoom a,
|
||||
.dark .leaflet-control-attribution,
|
||||
.dark .leaflet-popup-content-wrapper,
|
||||
.dark .leaflet-popup-tip {
|
||||
background: var(--card);
|
||||
color: var(--card-foreground);
|
||||
}
|
||||
|
||||
.dark .leaflet-control-zoom a {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.dark .leaflet-control-attribution a,
|
||||
.dark .leaflet-popup-content a {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import L from "leaflet";
|
||||
import { MapContainer, Marker, Popup, TileLayer, Tooltip, Circle, useMap, useMapEvents } from "react-leaflet";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Mail, MapPin, Phone, RefreshCw, Search, Tag } from "lucide-react";
|
||||
import { Maximize2, Mail, MapPin, Minimize2, Move, Phone, RefreshCw, Search, Tag } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getAllSuppliers } from "@/lib/pb-helpers";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import type { Supplier } from "@/lib/types";
|
||||
|
||||
type SupplierPoint = Supplier & {
|
||||
|
|
@ -27,8 +29,12 @@ type SupplierCluster = {
|
|||
const DEFAULT_CENTER: [number, number] = [-26.2041, 28.0473];
|
||||
const CLUSTER_DISTANCE_KM = 8;
|
||||
const CLUSTER_ZOOM_BREAKPOINT = 12;
|
||||
const LABEL_ZOOM_BREAKPOINT = 13;
|
||||
const LABEL_ZOOM_BREAKPOINT = 12;
|
||||
const CLUSTER_COLORS = ["#1B76BB", "#E64B4B", "#F4B842", "#169B62", "#7C3AED", "#EA580C", "#0F766E"];
|
||||
const LIGHT_TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
const DARK_TILE_URL = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
|
||||
const LIGHT_TILE_ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
|
||||
const DARK_TILE_ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>';
|
||||
|
||||
function parseCoordinates(value?: string): [number, number] | null {
|
||||
if (!value) return null;
|
||||
|
|
@ -119,7 +125,13 @@ function buildClusters(points: SupplierPoint[], distanceKmLimit = CLUSTER_DISTAN
|
|||
return clusters;
|
||||
}
|
||||
|
||||
function ZoomWatcher({ onZoomChange }: { onZoomChange: (zoom: number) => void }) {
|
||||
function MapWatcher({ isFullscreen, onZoomChange }: { isFullscreen: boolean; onZoomChange: (zoom: number) => void }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
window.setTimeout(() => map.invalidateSize(), 180);
|
||||
}, [isFullscreen, map]);
|
||||
|
||||
useMapEvents({
|
||||
zoomend: (event) => onZoomChange(event.target.getZoom()),
|
||||
});
|
||||
|
|
@ -136,7 +148,7 @@ function ClusterMarker({ cluster }: { cluster: SupplierCluster }) {
|
|||
return (
|
||||
<Marker position={[supplier.lat, supplier.lng]} icon={markerIcon(cluster.color, "")}>
|
||||
<Popup>
|
||||
<SupplierPopup supplier={supplier} />
|
||||
<SupplierPopup supplier={supplier} editMode={false} />
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
|
|
@ -147,26 +159,16 @@ function ClusterMarker({ cluster }: { cluster: SupplierCluster }) {
|
|||
position={cluster.center}
|
||||
icon={clusterIcon(cluster.color, count)}
|
||||
eventHandlers={{
|
||||
click: () => map.flyTo(cluster.center, Math.min(map.getZoom() + 2, CLUSTER_ZOOM_BREAKPOINT), { duration: 0.4 }),
|
||||
click: () => {
|
||||
map.closePopup();
|
||||
map.flyTo(cluster.center, Math.min(map.getZoom() + 2, CLUSTER_ZOOM_BREAKPOINT), { duration: 0.4 });
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="max-h-64 space-y-2 overflow-auto text-sm">
|
||||
<p className="font-semibold">{count} suppliers in this area</p>
|
||||
{cluster.suppliers.slice(0, 12).map((supplier) => (
|
||||
<div key={supplier.id} className="border-t pt-2 first:border-t-0 first:pt-0">
|
||||
<p className="font-medium">{supplier.suppliername || "Unnamed supplier"}</p>
|
||||
{supplier.supplieraddress && <p className="text-xs text-muted-foreground">{supplier.supplieraddress}</p>}
|
||||
</div>
|
||||
))}
|
||||
{count > 12 && <p className="text-xs text-muted-foreground">Zoom in to see {count - 12} more.</p>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SupplierPopup({ supplier }: { supplier: SupplierPoint }) {
|
||||
function SupplierPopup({ supplier, editMode }: { supplier: SupplierPoint; editMode: boolean }) {
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-semibold">{supplier.suppliername || "Unnamed supplier"}</p>
|
||||
|
|
@ -174,15 +176,24 @@ function SupplierPopup({ supplier }: { supplier: SupplierPoint }) {
|
|||
{supplier.suppliernumber && <p>Phone: {supplier.suppliernumber}</p>}
|
||||
{supplier.email && <p>Email: {supplier.email}</p>}
|
||||
{supplier.supply_tags && <p>Tags: {supplier.supply_tags}</p>}
|
||||
{editMode && (
|
||||
<p className="pt-1 text-xs font-medium text-primary">
|
||||
Drag this marker to correct its saved location.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SupplierMap() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [zoom, setZoom] = useState(10);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const isDarkMap = resolvedTheme === "dark";
|
||||
|
||||
const fetchSuppliers = async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -199,6 +210,25 @@ export function SupplierMap() {
|
|||
fetchSuppliers();
|
||||
}, []);
|
||||
|
||||
const saveSupplierLocation = async (supplier: SupplierPoint, lat: number, lng: number) => {
|
||||
const coordinates = `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||
|
||||
setSuppliers((current) =>
|
||||
current.map((item) => (item.id === supplier.id ? { ...item, coordinates } : item))
|
||||
);
|
||||
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
await pb.collection("suppliers").update(supplier.id, { coordinates });
|
||||
toast.success(`Updated ${supplier.suppliername || "supplier"} location`);
|
||||
} catch (err) {
|
||||
setSuppliers((current) =>
|
||||
current.map((item) => (item.id === supplier.id ? { ...item, coordinates: supplier.coordinates } : item))
|
||||
);
|
||||
toast.error(err instanceof Error ? err.message : "Failed to update supplier location");
|
||||
}
|
||||
};
|
||||
|
||||
const { mapped, missing } = useMemo(() => {
|
||||
const filtered = suppliers.filter((supplier) => {
|
||||
const query = search.trim().toLowerCase();
|
||||
|
|
@ -259,7 +289,7 @@ export function SupplierMap() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">{clusters.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Grouped within {CLUSTER_DISTANCE_KM}km</p>
|
||||
<p className="text-xs text-muted-foreground">Grouped within {clusterDistanceKm}km at this zoom</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-primary/10">
|
||||
|
|
@ -283,20 +313,47 @@ export function SupplierMap() {
|
|||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={fetchSuppliers} disabled={loading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={editMode ? "default" : "outline"}
|
||||
onClick={() => setEditMode((enabled) => !enabled)}
|
||||
>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
{editMode ? "Done Editing" : "Edit Locations"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={fetchSuppliers} disabled={loading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<Card className="overflow-hidden border-primary/10">
|
||||
<div className="h-[68vh] min-h-[520px]">
|
||||
<Card className={`overflow-hidden border-primary/10 ${isFullscreen ? "fixed inset-0 z-[45] rounded-none border-0" : "relative z-0"}`}>
|
||||
<div className={`${isFullscreen ? "h-dvh min-h-0" : "h-[68vh] min-h-[520px]"} relative`}>
|
||||
<div className="absolute right-3 top-3 z-[30] flex flex-col gap-2 sm:flex-row">
|
||||
{editMode && (
|
||||
<div className="rounded-full border bg-background/95 px-3 py-2 text-xs font-medium shadow-sm backdrop-blur">
|
||||
Drag pins to save locations
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-9 rounded-full bg-background/95 shadow-sm backdrop-blur"
|
||||
onClick={() => setIsFullscreen((fullscreen) => !fullscreen)}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="mr-2 h-4 w-4" /> : <Maximize2 className="mr-2 h-4 w-4" />}
|
||||
{isFullscreen ? "Exit" : "Fullscreen"}
|
||||
</Button>
|
||||
</div>
|
||||
<MapContainer center={mapCenter} zoom={mapped.length ? 10 : 6} scrollWheelZoom className="h-full w-full">
|
||||
<ZoomWatcher onZoomChange={setZoom} />
|
||||
<MapWatcher isFullscreen={isFullscreen} onZoomChange={setZoom} />
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
key={isDarkMap ? "dark" : "light"}
|
||||
attribution={isDarkMap ? DARK_TILE_ATTRIBUTION : LIGHT_TILE_ATTRIBUTION}
|
||||
url={isDarkMap ? DARK_TILE_URL : LIGHT_TILE_URL}
|
||||
/>
|
||||
{!showIndividualSuppliers &&
|
||||
clusters.map((cluster) => (
|
||||
|
|
@ -320,6 +377,14 @@ export function SupplierMap() {
|
|||
key={supplier.id}
|
||||
position={[supplier.lat, supplier.lng]}
|
||||
icon={markerIcon(cluster?.color || CLUSTER_COLORS[0], label)}
|
||||
draggable={editMode}
|
||||
eventHandlers={{
|
||||
dragend: (event) => {
|
||||
const marker = event.target as L.Marker;
|
||||
const position = marker.getLatLng();
|
||||
void saveSupplierLocation(supplier, position.lat, position.lng);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{showLabels && (
|
||||
<Tooltip direction="top" offset={[0, -12]} permanent className="supplier-map-label">
|
||||
|
|
@ -327,7 +392,7 @@ export function SupplierMap() {
|
|||
</Tooltip>
|
||||
)}
|
||||
<Popup>
|
||||
<SupplierPopup supplier={supplier} />
|
||||
<SupplierPopup supplier={supplier} editMode={editMode} />
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue