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:
SJCoetz 2026-05-27 17:57:24 +02:00
parent a6ac88a06f
commit 83935c990b
2 changed files with 129 additions and 31 deletions

View file

@ -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);
}

View file

@ -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 = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
const DARK_TILE_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <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='&copy; <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>
);