From 8f59d3ba72d2099bbb91fca0067b6bc0219649c3 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 4 Feb 2026 17:42:41 -0800 Subject: [PATCH] feat: add map page --- apps/web/app/map/page.tsx | 96 +++++++++++++++++++++++++++++++++++++++ apps/web/app/page.tsx | 3 ++ bun.lock | 13 ++++++ package.json | 3 ++ 4 files changed, 115 insertions(+) create mode 100644 apps/web/app/map/page.tsx diff --git a/apps/web/app/map/page.tsx b/apps/web/app/map/page.tsx new file mode 100644 index 0000000..8caad37 --- /dev/null +++ b/apps/web/app/map/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import "leaflet/dist/leaflet.css"; +import L from "leaflet"; +import { useEffect, useRef, useState } from "react"; +import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet"; + +type GeoPoint = { + id: string; + gps_lat: number | null; + gps_lon: number | null; +}; + +function MapContent({ points, error }: { points: GeoPoint[]; error: string | null }) { + const map = useMap(); + const markersRef = useRef([]); + + useEffect(() => { + markersRef.current.forEach((marker) => marker.remove()); + markersRef.current = []; + + if (points.length === 0) return; + + points.forEach((point) => { + if (point.gps_lat === null || point.gps_lon === null) return; + + const marker = L.marker([point.gps_lat, point.gps_lon]); + marker.addTo(map); + markersRef.current.push(marker); + }); + + if (points.length > 0) { + const group = L.featureGroup(markersRef.current); + map.fitBounds(group.getBounds().pad(0.1)); + } + }, [points, map]); + + return null; +} + +export default function MapPage() { + const [points, setPoints] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/geo") + .then((res) => { + if (!res.ok) throw new Error("Failed to fetch geo points"); + return res.json(); + }) + .then((data) => { + setPoints(data); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : "Unknown error"); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + return ( +
+
+

Map

+
+ + {loading ? ( +
+ Loading map... +
+ ) : error ? ( +
+ Error: {error} +
+ ) : points.length === 0 ? ( +
+ No GPS points available +
+ ) : ( +
+ + + + +
+ )} +
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index eb51fa1..767df17 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -16,6 +16,9 @@ export default function HomePage() {

{getAppName()}

    +
  • + Map +
  • Admin
  • diff --git a/bun.lock b/bun.lock index eed8bb3..15356bc 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,12 @@ "": { "name": "tline", "dependencies": { + "leaflet": "^1.9.4", + "react-leaflet": "^5.0.0", "zod": "^4.2.1", }, "devDependencies": { + "@types/leaflet": "^1.9.21", "@types/node": "^20.19.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -272,6 +275,8 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw=="], + "@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="], + "@smithy/abort-controller": ["@smithy/abort-controller@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-rzMY6CaKx2qxrbYbqjXWS0plqEy7LOdKHS0bg4ixJ6aoGDPNUcLWk/FRNuCILh7GKLG9TFUXYYeQQldMBBwuyw=="], "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA=="], @@ -390,8 +395,12 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="], + "@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="], "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], @@ -518,6 +527,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -576,6 +587,8 @@ "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + "react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="], + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], diff --git a/package.json b/package.json index f111731..c375263 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "format": "bunx prettier . --check" }, "devDependencies": { + "@types/leaflet": "^1.9.21", "@types/node": "^20.19.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -24,6 +25,8 @@ "typescript": "^5.9.3" }, "dependencies": { + "leaflet": "^1.9.4", + "react-leaflet": "^5.0.0", "zod": "^4.2.1" } }