From 83935c990b97db3195efefaea2d86d7caf604cc2 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 27 May 2026 17:57:24 +0200 Subject: [PATCH] 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. --- src/app/globals.css | 33 +++++++++ src/components/supplier-map.tsx | 127 ++++++++++++++++++++++++-------- 2 files changed, 129 insertions(+), 31 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 88cb57b..2553654 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); +} diff --git a/src/components/supplier-map.tsx b/src/components/supplier-map.tsx index 258011b..8021e52 100644 --- a/src/components/supplier-map.tsx +++ b/src/components/supplier-map.tsx @@ -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 = '© OpenStreetMap'; +const DARK_TILE_ATTRIBUTION = '© OpenStreetMap © CARTO'; 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 ( - + ); @@ -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 }); + }, }} - > - -
-

{count} suppliers in this area

- {cluster.suppliers.slice(0, 12).map((supplier) => ( -
-

{supplier.suppliername || "Unnamed supplier"}

- {supplier.supplieraddress &&

{supplier.supplieraddress}

} -
- ))} - {count > 12 &&

Zoom in to see {count - 12} more.

} -
-
- + /> ); } -function SupplierPopup({ supplier }: { supplier: SupplierPoint }) { +function SupplierPopup({ supplier, editMode }: { supplier: SupplierPoint; editMode: boolean }) { return (

{supplier.suppliername || "Unnamed supplier"}

@@ -174,15 +176,24 @@ function SupplierPopup({ supplier }: { supplier: SupplierPoint }) { {supplier.suppliernumber &&

Phone: {supplier.suppliernumber}

} {supplier.email &&

Email: {supplier.email}

} {supplier.supply_tags &&

Tags: {supplier.supply_tags}

} + {editMode && ( +

+ Drag this marker to correct its saved location. +

+ )}
); } export function SupplierMap() { + const { resolvedTheme } = useTheme(); const [suppliers, setSuppliers] = useState([]); 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() {

{clusters.length}

-

Grouped within {CLUSTER_DISTANCE_KM}km

+

Grouped within {clusterDistanceKm}km at this zoom

@@ -283,20 +313,47 @@ export function SupplierMap() { className="pl-9" /> - +
+ + +
- -
+ +
+
+ {editMode && ( +
+ Drag pins to save locations +
+ )} + +
- + {!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 && ( @@ -327,7 +392,7 @@ export function SupplierMap() { )} - + );