diff --git a/client/components.json b/client/components.json new file mode 100644 index 0000000..57039a2 --- /dev/null +++ b/client/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/client/package-lock.json b/client/package-lock.json index 7424f4b..dd3ec7a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,14 +8,20 @@ "name": "client", "version": "0.0.0", "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "leaflet": "^1.9.4", "leaflet.heat": "^0.2.0", + "lucide-react": "^0.545.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "tailwind-merge": "^3.3.1" + "tailwind-merge": "^3.3.1", + "tslib": "^2.8.1" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -726,6 +732,72 @@ "node": ">=14" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -741,6 +813,290 @@ } } }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -759,6 +1115,121 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-beta.41", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.41.tgz", @@ -1115,7 +1586,7 @@ "version": "19.2.1", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -1527,6 +1998,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -1894,6 +2377,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2321,6 +2810,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -2982,6 +3480,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.545.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz", + "integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3510,6 +4017,75 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -4034,9 +4610,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -4137,6 +4711,49 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/client/package.json b/client/package.json index c614c10..f72bac6 100644 --- a/client/package.json +++ b/client/package.json @@ -10,14 +10,20 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "leaflet": "^1.9.4", "leaflet.heat": "^0.2.0", + "lucide-react": "^0.545.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "tailwind-merge": "^3.3.1" + "tailwind-merge": "^3.3.1", + "tslib": "^2.8.1" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index 524d09b..1e74fe0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,76 +1,36 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import L, { type LayerGroup, type LeafletMouseEvent, type Map as LeafletMap } from 'leaflet' -import 'leaflet.heat' +import { useCallback, useMemo, useState } from 'react' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' -import { Separator } from '@/components/ui/separator' -import { cn, distanceInKm, formatCoordinate, formatRelativeTime } from '@/lib/utils' +import { AppHeader } from '@/components/layout/AppHeader' +import { ActivityPanel } from '@/components/panels/ActivityPanel' +import { HotspotStatsPanel } from '@/components/panels/HotspotStatsPanel' +import { OverviewPanel } from '@/components/panels/OverviewPanel' +import { MapViewport } from '@/components/map/MapViewport' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { useHotspotFeed } from '@/hooks/useHotspotFeed' +import { useLeafletHeatmap } from '@/hooks/useLeafletHeatmap' +import { useUserLocation } from '@/hooks/useUserLocation' +import { distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils' +import type { FeedStatus, LatLng } from '@/types/api' -const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals' -const VISIBLE_RADIUS_KM = 5 +const RADIUS_KM = 1 -type Status = 'loading' | 'idle' | 'error' | 'posting' | 'refreshing' - -type ApiPoint = { - id: number - lat: number - lng: number - createdAt: string - userKey: string -} - -type ApiDensity = { - lat: number - lng: number - intensity: number -} - -type ApiResponse = { - clientKey?: string - points?: ApiPoint[] - density?: ApiDensity[] - latestByUser?: ApiPoint[] - totals?: { - points: number - contributors: number - } - updatedAt?: string -} - -type HeatPoint = [number, number, number?] - -type LeafletHeatLayer = L.Layer & { - setLatLngs(points: HeatPoint[]): LeafletHeatLayer -} - -interface HeatLayerOptions { - radius?: number - blur?: number - maxZoom?: number - max?: number - minOpacity?: number - gradient?: Record -} - -type LeafletWithHeat = typeof L & { - heatLayer?: (points: HeatPoint[], options?: HeatLayerOptions) => LeafletHeatLayer -} - -interface LatLng { - lat: number - lng: number -} - -function getStatusLabel(status: Status): string { +function getStatusLabel(status: FeedStatus): string { switch (status) { case 'loading': return 'Syncing map' case 'posting': - return 'Sending your signal' + return 'Sending signal' case 'refreshing': - return 'Updating hotspots' + return 'Updating heat' case 'error': return 'Offline' default: @@ -78,679 +38,257 @@ function getStatusLabel(status: Status): string { } } -function geolocationErrorMessage(error: GeolocationPositionError): string { - switch (error.code) { - case error.PERMISSION_DENIED: - return 'Location access denied. Enable it to view nearby pings.' - case error.POSITION_UNAVAILABLE: - return 'Unable to determine your position. Try again.' - case error.TIMEOUT: - return 'Timed out while fetching your location.' - default: - return 'Failed to retrieve your location.' - } -} - export default function App() { - const mapRef = useRef(null) - const mapContainerRef = useRef(null) - const heatLayerRef = useRef(null) - const markersLayerRef = useRef(null) - const zonesLayerRef = useRef(null) - const userLayerRef = useRef(null) - const locationWatchIdRef = useRef(null) - const statusRef = useRef('loading') - const initialLoadRef = useRef(true) - const hasCenteredOnUserRef = useRef(false) + const [pendingSpot, setPendingSpot] = useState(null) + const [isConfirmOpen, setIsConfirmOpen] = useState(false) + const [isConfirming, setIsConfirming] = useState(false) - const [status, setStatus] = useState('loading') - const [rawPoints, setRawPoints] = useState([]) - const [rawDensity, setRawDensity] = useState([]) - const [rawLatestByUser, setRawLatestByUser] = useState([]) - const [clientKey, setClientKey] = useState(null) - const [lastUpdated, setLastUpdated] = useState(null) - const [errorMessage, setErrorMessage] = useState(null) - const [userLocation, setUserLocation] = useState(null) - const [locationError, setLocationError] = useState(null) - const [isRequestingLocation, setIsRequestingLocation] = useState(false) + const { + status, + errorMessage, + submitPoint, + fetchSnapshot, + selectVisibleDensity, + selectVisiblePoints, + selectVisibleLatestByUser, + myLatestPoint, + lastUpdated, + } = useHotspotFeed() - const setStatusSafe = useCallback((next: Status) => { - statusRef.current = next - setStatus(next) - }, []) + const { location: userLocation, error: locationError, isRequesting: isRequestingLocation } = useUserLocation() - const startLocationWatch = useCallback(() => { - if (typeof navigator === 'undefined' || !navigator.geolocation) { - setLocationError('Geolocation is not supported in this browser.') - setIsRequestingLocation(false) - return - } - - setIsRequestingLocation(true) - setLocationError(null) - - if (locationWatchIdRef.current !== null) { - navigator.geolocation.clearWatch(locationWatchIdRef.current) - locationWatchIdRef.current = null - } - - navigator.geolocation.getCurrentPosition( - (position) => { - setUserLocation({ lat: position.coords.latitude, lng: position.coords.longitude }) - setIsRequestingLocation(false) - }, - (error) => { - setLocationError(geolocationErrorMessage(error)) - setIsRequestingLocation(false) - }, - { enableHighAccuracy: true, timeout: 10000 }, - ) - - const watchId = navigator.geolocation.watchPosition( - (position) => { - setUserLocation({ lat: position.coords.latitude, lng: position.coords.longitude }) - setLocationError(null) - setIsRequestingLocation(false) - }, - (error) => { - setLocationError(geolocationErrorMessage(error)) - setIsRequestingLocation(false) - }, - { enableHighAccuracy: true, maximumAge: 15000, timeout: 10000 }, - ) - - locationWatchIdRef.current = watchId - }, []) - - const fetchSnapshot = useCallback( - async (options?: { silent?: boolean }) => { - const silent = options?.silent ?? false - const previousStatus = statusRef.current - const isInitial = initialLoadRef.current - - if (previousStatus !== 'posting') { - if (isInitial) { - setStatusSafe('loading') - } else if (!silent) { - setStatusSafe('refreshing') - } - } - - try { - const response = await fetch(`${API_BASE}?limit=750`, { - cache: 'no-store', - }) - - if (!response.ok) { - throw new Error('Unable to reach the hotspot feed.') - } - - const data: ApiResponse = await response.json() - setRawPoints(data.points ?? []) - setRawDensity(data.density ?? []) - setRawLatestByUser(data.latestByUser ?? []) - setClientKey(data.clientKey ?? null) - setLastUpdated(data.updatedAt ?? new Date().toISOString()) - setErrorMessage(null) - initialLoadRef.current = false - - const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle' - setStatusSafe(nextStatus) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error while loading hotspots.' - setErrorMessage(message) - if (initialLoadRef.current) { - setStatusSafe('error') - } else if (previousStatus !== 'posting') { - setStatusSafe('idle') - } - } - }, - [setStatusSafe], + const visibleDensity = useMemo( + () => selectVisibleDensity(userLocation ?? null), + [selectVisibleDensity, userLocation], ) - const submitPoint = useCallback( - async (lat: number, lng: number) => { - setStatusSafe('posting') - try { - const response = await fetch(API_BASE, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ lat, lng }), - }) - - if (!response.ok) { - const payload = await response.json().catch(() => null) - const message = payload?.message ?? 'Unable to store your signal.' - throw new Error(message) - } - - await fetchSnapshot({ silent: true }) - setStatusSafe('idle') - } catch (error) { - const message = error instanceof Error ? error.message : 'Something went wrong while saving your signal.' - setErrorMessage(message) - setStatusSafe('error') - throw error - } - }, - [fetchSnapshot, setStatusSafe], + const visiblePoints = useMemo( + () => selectVisiblePoints(userLocation ?? null), + [selectVisiblePoints, userLocation], ) - const handleMapClick = useCallback( - ({ lat, lng }: LatLng) => { - submitPoint(lat, lng).catch(() => {}) - }, - [submitPoint], + const visibleLatestByUser = useMemo( + () => selectVisibleLatestByUser(userLocation ?? null), + [selectVisibleLatestByUser, userLocation], ) - const initialiseMap = useCallback(() => { - if (mapRef.current || !mapContainerRef.current) { - return - } - - const leaflet = L as LeafletWithHeat - const container = mapContainerRef.current - - const map = leaflet - .map(container, { - worldCopyJump: true, - minZoom: 2, - zoomControl: true, - }) - .setView([20, 0], 2) - - leaflet - .tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors', - crossOrigin: true, - maxZoom: 19, - }) - .addTo(map) - - const heatLayer = - typeof leaflet.heatLayer === 'function' - ? leaflet.heatLayer([], { - radius: 32, - blur: 24, - maxZoom: 12, - gradient: { - 0.2: '#38bdf8', - 0.4: '#0ea5e9', - 0.6: '#fbbf24', - 0.8: '#f97316', - 1.0: '#ef4444', - }, - }) - : null - - const markersLayer = leaflet.layerGroup().addTo(map) - const zonesLayer = leaflet.layerGroup().addTo(map) - const userLayer = leaflet.layerGroup().addTo(map) - - if (heatLayer) { - heatLayer.addTo(map) - } - - const onClick = (event: LeafletMouseEvent) => { - const { lat, lng } = event.latlng - if (typeof lat === 'number' && typeof lng === 'number') { - handleMapClick({ lat, lng }) - } - } - - map.on('click', onClick) - - map.whenReady(() => { - requestAnimationFrame(() => { - map.invalidateSize() - }) - }) - - const onResize = () => { - requestAnimationFrame(() => { - map.invalidateSize() - }) - } - - window.addEventListener('resize', onResize) - - mapRef.current = map - heatLayerRef.current = heatLayer - markersLayerRef.current = markersLayer - zonesLayerRef.current = zonesLayer - userLayerRef.current = userLayer - - return () => { - map.off('click', onClick) - window.removeEventListener('resize', onResize) - map.remove() - mapRef.current = null - heatLayerRef.current = null - markersLayerRef.current = null - zonesLayerRef.current = null - userLayerRef.current = null - } - }, [handleMapClick]) - - useEffect(() => { - const cleanup = initialiseMap() - const sizeTimer = window.setTimeout(() => { - if (mapRef.current) { - mapRef.current.invalidateSize() - } - }, 150) - fetchSnapshot().catch(() => {}) - const interval = window.setInterval(() => { - fetchSnapshot({ silent: true }).catch(() => {}) - }, 7000) - - startLocationWatch() - - return () => { - if (typeof cleanup === 'function') { - cleanup() - } - window.clearTimeout(sizeTimer) - window.clearInterval(interval) - if (typeof navigator !== 'undefined' && navigator.geolocation && locationWatchIdRef.current !== null) { - navigator.geolocation.clearWatch(locationWatchIdRef.current) - } - } - }, [fetchSnapshot, initialiseMap, startLocationWatch]) - - const visibleDensity = useMemo(() => { - if (!userLocation) { - return [] - } - return rawDensity.filter((entry) => distanceInKm(userLocation, entry) <= VISIBLE_RADIUS_KM) - }, [rawDensity, userLocation]) - - const visibleDangerZones = useMemo(() => { - if (!visibleDensity.length) { - return [] - } - return visibleDensity.slice(0, 3) - }, [visibleDensity]) - - const visiblePoints = useMemo(() => { - if (!userLocation) { - return [] - } - return rawPoints.filter((point) => distanceInKm(userLocation, point) <= VISIBLE_RADIUS_KM) - }, [rawPoints, userLocation]) - - const visibleLatestByUser = useMemo(() => { - if (!userLocation) { - return [] - } - return rawLatestByUser.filter((point) => distanceInKm(userLocation, point) <= VISIBLE_RADIUS_KM) - }, [rawLatestByUser, userLocation]) - const localTotals = useMemo(() => { const uniqueUsers = new Set() visibleLatestByUser.forEach((point) => uniqueUsers.add(point.userKey)) - - return { - points: visiblePoints.length, - contributors: uniqueUsers.size, - } + return { points: visiblePoints.length, contributors: uniqueUsers.size } }, [visibleLatestByUser, visiblePoints]) - useEffect(() => { - const heatLayer = heatLayerRef.current - if (!heatLayer) { - return + const myVisibleSignal = useMemo(() => { + if (!myLatestPoint) { + return null } - - if (!visibleDensity.length) { - heatLayer.setLatLngs([]) - return + if (userLocation && distanceInKm(userLocation, myLatestPoint) > RADIUS_KM) { + return null } + return myLatestPoint + }, [myLatestPoint, userLocation]) - const maxIntensity = Math.max(...visibleDensity.map((entry) => entry.intensity)) || 1 - const heatPoints: HeatPoint[] = visibleDensity.map((entry) => [ - entry.lat, - entry.lng, - Math.max(0.25, entry.intensity / maxIntensity), - ]) - heatLayer.setLatLngs(heatPoints) - }, [visibleDensity]) - - useEffect(() => { - const layer = zonesLayerRef.current - if (!layer) { - return - } - - layer.clearLayers() - - if (!visibleDangerZones.length) { - return - } - - const maxIntensity = visibleDangerZones[0]?.intensity ?? 0 - - visibleDangerZones.forEach((zone, index) => { - const intensityRatio = maxIntensity ? zone.intensity / maxIntensity : 0.5 - const radius = 400 + intensityRatio * 1600 - const circle = L.circle([zone.lat, zone.lng], { - radius, - color: index === 0 ? '#ef4444' : '#f97316', - weight: 2, - fillColor: '#ef4444', - fillOpacity: Math.max(0.12, 0.22 - index * 0.04), - }) - - circle.addTo(layer).bindTooltip(`Hotspot #${index + 1}\nIntensity: ${zone.intensity}`, { - direction: 'top', - offset: [0, -12], - }) - }) - }, [visibleDangerZones]) - - useEffect(() => { - const layer = markersLayerRef.current - if (!layer) { - return - } - - layer.clearLayers() - - const latestMap = new Map() - visibleLatestByUser.forEach((point) => { - const existing = latestMap.get(point.userKey) - if (!existing || existing.createdAt < point.createdAt) { - latestMap.set(point.userKey, point) - } - }) - - latestMap.forEach((point) => { - const isSelf = clientKey && point.userKey === clientKey - const marker = L.circleMarker([point.lat, point.lng], { - radius: isSelf ? 10 : 6, - color: isSelf ? '#38bdf8' : '#94a3b8', - weight: isSelf ? 3 : 1.5, - opacity: 0.9, - fillOpacity: isSelf ? 0.45 : 0.28, - fillColor: isSelf ? '#38bdf8' : '#cbd5f5', - }) - - marker.addTo(layer).bindTooltip( - `User ${point.userKey}\n${formatCoordinate(point.lat)}, ${formatCoordinate(point.lng)}`, - { - direction: 'top', - offset: [0, -8], - }, - ) - }) - }, [visibleLatestByUser, clientKey]) - - useEffect(() => { - const layer = userLayerRef.current - if (!layer) { - return - } - - layer.clearLayers() - - if (!userLocation) { - return - } - - L.circle([userLocation.lat, userLocation.lng], { - radius: VISIBLE_RADIUS_KM * 1000, - color: '#38bdf8', - weight: 1.5, - opacity: 0.6, - dashArray: '6 6', - fillColor: '#38bdf8', - fillOpacity: 0.05, - }).addTo(layer) - - L.circleMarker([userLocation.lat, userLocation.lng], { - radius: 7, - color: '#38bdf8', - weight: 2, - opacity: 0.9, - fillColor: '#38bdf8', - fillOpacity: 0.5, - }).addTo(layer) - }, [userLocation]) - - useEffect(() => { - if (!mapRef.current || !userLocation || hasCenteredOnUserRef.current) { - return - } - - mapRef.current.setView([userLocation.lat, userLocation.lng], 12, { - animate: true, - }) - hasCenteredOnUserRef.current = true - }, [userLocation]) - - const recentActivity = useMemo(() => visiblePoints.slice(0, 8), [visiblePoints]) const statusLabel = getStatusLabel(status) - const statusBadgeClass = cn( - 'inline-flex items-center gap-2 rounded-full border border-border/60 bg-secondary/50 px-3 py-1 text-xs font-medium tracking-wide text-muted-foreground backdrop-blur', - status === 'error' && 'border-destructive/40 bg-destructive/15 text-destructive', - ) - const statusAccentClass = status === 'error' ? 'text-destructive' : 'text-primary' const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated) : 'never' const isLoading = status === 'loading' const isPosting = status === 'posting' - const heatMax = visibleDangerZones[0]?.intensity ?? 0 + const isRefreshing = status === 'refreshing' const hasLocation = Boolean(userLocation) - const myLatestPoint = useMemo(() => { - if (!clientKey) { - return null - } - const candidate = rawLatestByUser.find((point) => point.userKey === clientKey) - if (!candidate) { - return null - } - if (userLocation && distanceInKm(userLocation, candidate) > VISIBLE_RADIUS_KM) { - return null - } - return candidate - }, [rawLatestByUser, clientKey, userLocation]) - - const focusDangerZone = useCallback(() => { - if (!mapRef.current || !visibleDangerZones.length) { - return - } - - const zone = visibleDangerZones[0] - mapRef.current.setView([zone.lat, zone.lng], 13, { - animate: true, - }) - }, [visibleDangerZones]) - - const focusMySignal = useCallback(() => { - if (!mapRef.current || !myLatestPoint) { - return - } - - mapRef.current.setView([myLatestPoint.lat, myLatestPoint.lng], 14, { - animate: true, - }) - }, [myLatestPoint]) - - const refreshNow = useCallback(() => { - fetchSnapshot().catch(() => {}) - }, [fetchSnapshot]) + const showLocationCta = !hasLocation || Boolean(locationError) const locationHint = locationError ? locationError : hasLocation - ? `Showing reports within ${VISIBLE_RADIUS_KM}km of you.` + ? `Showing reports within ${RADIUS_KM}km of you.` : isRequestingLocation - ? 'Fetching your location...' - : 'Allow location access to view nearby danger pings.' + ? 'Fetching your location…' + : 'Allow location access to view nearby reports.' - const showLocationCta = !hasLocation || Boolean(locationError) + const { mapContainerRef, focusOn, fitToHeat } = useLeafletHeatmap({ + heatCells: visibleDensity, + userLocation: userLocation ?? null, + onRequestSpot: (position) => { + setPendingSpot(position) + setIsConfirmOpen(true) + }, + }) + + const handleConfirmSignal = useCallback(async () => { + if (!pendingSpot) { + return + } + setIsConfirming(true) + const result = await submitPoint(pendingSpot.lat, pendingSpot.lng) + setIsConfirming(false) + if (result.success) { + setIsConfirmOpen(false) + setPendingSpot(null) + } + }, [pendingSpot, submitPoint]) + + const handleRefresh = useCallback(() => { + fetchSnapshot().catch(() => undefined) + }, [fetchSnapshot]) + + const handleFocusHeat = useCallback(() => { + fitToHeat() + }, [fitToHeat]) + + const handleLocateUser = useCallback(() => { + if (userLocation) { + focusOn(userLocation, 14) + } + }, [focusOn, userLocation]) + + const handleFocusMySignal = useCallback(() => { + if (myVisibleSignal) { + focusOn({ lat: myVisibleSignal.lat, lng: myVisibleSignal.lng }, 15) + } + }, [focusOn, myVisibleSignal]) + + const handleManualReport = useCallback(() => { + if (!userLocation) { + return + } + setPendingSpot(userLocation) + setIsConfirmOpen(true) + }, [userLocation]) + + const dangerCells = useMemo( + () => + [...visibleDensity] + .sort((a, b) => b.intensity - a.intensity) + .slice(0, 5) + .map((cell, index) => ({ + id: `${cell.lat}-${cell.lng}-${index}`, + title: `Hotspot #${index + 1}`, + subtitle: hasLocation + ? `${distanceInKm(userLocation!, cell).toFixed(2)}km away · ${formatCoordinate(cell.lat)}°, ${formatCoordinate(cell.lng)}°` + : `${formatCoordinate(cell.lat)}°, ${formatCoordinate(cell.lng)}°`, + intensity: cell.intensity, + onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15), + })), + [visibleDensity, hasLocation, userLocation, focusOn], + ) + + const recentActivity = useMemo( + () => + [...visiblePoints] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, 8) + .map((point) => ({ + id: point.id, + title: `${formatCoordinate(point.lat)}°, ${formatCoordinate(point.lng)}°`, + subtitle: `User ${point.userKey.slice(0, 4).toUpperCase()}`, + timestampLabel: formatRelativeTime(point.createdAt), + distanceLabel: hasLocation + ? `${distanceInKm(userLocation!, point).toFixed(2)}km away` + : formatTimestamp(point.createdAt), + onFocus: () => focusOn({ lat: point.lat, lng: point.lng }, 15), + })), + [visiblePoints, hasLocation, userLocation, focusOn], + ) + + const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat) : '--' + const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng) : '--' + const isDialogDisabled = !pendingSpot || isConfirming return (
-
-
-
-

SignalMap

-

- Crowd-powered danger zones layered on Leaflet + OpenStreetMap. -

-
-
- - - - {statusLabel} - - - - - -
-
-
+ -
-
- - - Danger zone intel - Highest intensity cells near you right now. - - - {!hasLocation && ( -

- We need your location to reveal community alerts around you. -

- )} - {hasLocation && visibleDangerZones.length === 0 && ( -

- No hotspots within {VISIBLE_RADIUS_KM}km yet. Tap anywhere on the map to raise the first signal. -

- )} - {visibleDangerZones.map((zone, index) => ( -
-
- Hotspot #{index + 1} - - {formatCoordinate(zone.lat)}, {formatCoordinate(zone.lng)} - -
- ×{zone.intensity} -
- ))} -
- - {hasLocation ? ( - - Heatmap max intensity: {heatMax || '—'} • Last sync {lastUpdatedLabel} - - ) : ( - Totals unavailable until we can access your position. - )} - -
- - - - Community feed - - Local reports: {localTotals.points} · Unique spotters: {localTotals.contributors} - - - - {!hasLocation && ( -

- Allow location access to tune the feed to your area. -

- )} - {hasLocation && recentActivity.length === 0 && ( -

- Waiting for the first signals nearby. Click the map to broadcast a hazard ping. -

- )} - {recentActivity.map((item) => ( -
-
- - {formatCoordinate(item.lat)}, {formatCoordinate(item.lng)} - - - {item.userKey === clientKey ? 'You' : `User ${item.userKey}`} - -
- {formatRelativeTime(item.createdAt)} -
- ))} -
-
- -
- -
- {status === 'error' && errorMessage && ( -
- {errorMessage} - -
- )} - -
-
+
+
+ + + +
+ +
+ -
-
- Collaborative heatmap - LIVE -
-

- Click anywhere to drop a signal. We blend every report into a shared danger zone heatmap focused on your - surroundings. -

- -
- Local signals: {localTotals.points} • Last sync {lastUpdatedLabel} - {locationHint} -
- {showLocationCta && ( -
- -
- )} -
+ + { + setIsConfirmOpen(nextOpen) + if (!nextOpen) { + setPendingSpot(null) + setIsConfirming(false) + } + }} + > + + + Confirm new signal + + You're about to publish a community alert at these coordinates. Double-check the spot before confirming. + + +
+
+ Latitude + {confirmationLat}° +
+
+ Longitude + {confirmationLng}° +
+
+

+ Signals are visible to travellers within {RADIUS_KM}km and help the community stay aware of hotspots. +

+ + Cancel + { + event.preventDefault() + handleConfirmSignal().catch(() => undefined) + }} + > + {isConfirming ? 'Sending…' : 'Confirm signal'} + + +
+
) } diff --git a/client/src/components/layout/AppHeader.tsx b/client/src/components/layout/AppHeader.tsx new file mode 100644 index 0000000..b72277d --- /dev/null +++ b/client/src/components/layout/AppHeader.tsx @@ -0,0 +1,84 @@ +import { Flame, Focus, LocateFixed, MapPin, RefreshCw } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { ThemeToggle } from '@/components/layout/ThemeToggle' +import type { FeedStatus } from '@/types/api' + +interface AppHeaderProps { + status: FeedStatus + statusLabel: string + lastUpdatedLabel: string + onRefresh: () => void + onFocusHeat: () => void + onLocateUser: () => void + onFocusMySignal: () => void + disableRefresh: boolean + disableHeat: boolean + disableLocate: boolean + disableMySignal: boolean +} + +export function AppHeader({ + status, + statusLabel, + lastUpdatedLabel, + onRefresh, + onFocusHeat, + onLocateUser, + onFocusMySignal, + disableRefresh, + disableHeat, + disableLocate, + disableMySignal, +}: AppHeaderProps) { + const isError = status === 'error' + + return ( +
+
+
+ + + +
+ SignalMap + Crowd signals around your route +
+
+
+ + + + + + {statusLabel} + + {lastUpdatedLabel} + + + + + + +
+
+
+ ) +} diff --git a/client/src/components/layout/ThemeToggle.tsx b/client/src/components/layout/ThemeToggle.tsx new file mode 100644 index 0000000..ed19503 --- /dev/null +++ b/client/src/components/layout/ThemeToggle.tsx @@ -0,0 +1,19 @@ +import { Moon, Sun } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useTheme } from '@/hooks/useTheme' + +export function ThemeToggle() { + const { toggleTheme, isDark } = useTheme() + + return ( + + ) +} diff --git a/client/src/components/map/MapViewport.tsx b/client/src/components/map/MapViewport.tsx new file mode 100644 index 0000000..ac35ba7 --- /dev/null +++ b/client/src/components/map/MapViewport.tsx @@ -0,0 +1,32 @@ +import type { MutableRefObject } from 'react' + +import { cn } from '@/lib/utils' + +interface MapViewportProps { + containerRef: MutableRefObject + isPosting: boolean + isLoading: boolean + confirmationHint?: string | null +} + +export function MapViewport({ containerRef, isPosting, isLoading, confirmationHint }: MapViewportProps) { + return ( +
+
+
+
+ {(isPosting || isLoading) && ( +
+ + {isPosting ? 'Sending your signal…' : 'Syncing map…'} + +
+ )} + {confirmationHint && ( +
+ {confirmationHint} +
+ )} +
+ ) +} diff --git a/client/src/components/panels/ActivityPanel.tsx b/client/src/components/panels/ActivityPanel.tsx new file mode 100644 index 0000000..da0663c --- /dev/null +++ b/client/src/components/panels/ActivityPanel.tsx @@ -0,0 +1,63 @@ +import { Activity, ArrowRight } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { ScrollArea } from '@/components/ui/scroll-area' + +interface ActivityItem { + id: string | number + title: string + subtitle: string + timestampLabel: string + distanceLabel: string + onFocus: () => void +} + +interface ActivityPanelProps { + items: ActivityItem[] + emptyMessage: string +} + +export function ActivityPanel({ items, emptyMessage }: ActivityPanelProps) { + return ( + + + + + Live community pings + + Latest activity reported by nearby contributors. + + + {items.length === 0 &&

{emptyMessage}

} + {items.length > 0 && ( + +
    + {items.map((item) => ( +
  • +
    +
    + {item.title} + {item.subtitle} +
    + + {item.timestampLabel} + +
    +
    + {item.distanceLabel} + +
    +
  • + ))} +
+
+ )} +
+
+ ) +} diff --git a/client/src/components/panels/HotspotStatsPanel.tsx b/client/src/components/panels/HotspotStatsPanel.tsx new file mode 100644 index 0000000..7c5a669 --- /dev/null +++ b/client/src/components/panels/HotspotStatsPanel.tsx @@ -0,0 +1,68 @@ +import { Flame, MapPin } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { ScrollArea } from '@/components/ui/scroll-area' + +interface HotspotCellInfo { + id: string + title: string + subtitle: string + intensity: number + onFocus: () => void +} + +interface HotspotStatsPanelProps { + hasLocation: boolean + radiusKm: number + locationHint: string + cells: HotspotCellInfo[] +} + +export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells }: HotspotStatsPanelProps) { + return ( + + + + + Danger zone intel + + Highest intensity heat within {radiusKm}km. + + + {!hasLocation &&

{locationHint}

} + {hasLocation && cells.length === 0 && ( +

No active hotspots nearby. Tap the map to log a new signal.

+ )} + {cells.length > 0 && ( + +
    + {cells.map((cell) => ( +
  • +
    +
    + {cell.title} + {cell.subtitle} +
    + + {cell.intensity.toFixed(1)} + +
    + +
  • + ))} +
+
+ )} +
+
+ ) +} diff --git a/client/src/components/panels/OverviewPanel.tsx b/client/src/components/panels/OverviewPanel.tsx new file mode 100644 index 0000000..514e2e9 --- /dev/null +++ b/client/src/components/panels/OverviewPanel.tsx @@ -0,0 +1,82 @@ +import { AlertCircle, Radio } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' + +interface OverviewPanelProps { + nearbySignals: number + uniqueContributors: number + lastUpdatedLabel: string + mySignalLabel: string | null + errorMessage: string | null + onReport: () => void + onRetry: () => void + isPosting: boolean + locationHint: string + showLocationCta: boolean + disableReport: boolean +} + +export function OverviewPanel({ + nearbySignals, + uniqueContributors, + lastUpdatedLabel, + mySignalLabel, + errorMessage, + onReport, + onRetry, + isPosting, + locationHint, + showLocationCta, + disableReport, +}: OverviewPanelProps) { + return ( + + + + + Nearby coverage + + Signals refresh automatically every few seconds. + + +
+
+ Signals +

{nearbySignals}

+
+
+ Contributors +

{uniqueContributors}

+
+
+ {mySignalLabel && ( + + Your last signal {mySignalLabel} + + )} + {errorMessage ? ( +
+ +
+

{errorMessage}

+ +
+
+ ) : ( +

{locationHint}

+ )} + + {showLocationCta && !errorMessage && ( +

Allow location permissions to personalise the feed.

+ )} +

Last synced {lastUpdatedLabel}

+
+
+ ) +} diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..3310a91 --- /dev/null +++ b/client/src/components/ui/alert-dialog.tsx @@ -0,0 +1,109 @@ +import * as React from 'react' +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' + +import { cn } from '@/lib/utils' + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = 'AlertDialogHeader' + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = 'AlertDialogFooter' + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/client/src/components/ui/scroll-area.tsx b/client/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..cc1174e --- /dev/null +++ b/client/src/components/ui/scroll-area.tsx @@ -0,0 +1,40 @@ +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/client/src/components/ui/sheet.tsx b/client/src/components/ui/sheet.tsx new file mode 100644 index 0000000..a14c745 --- /dev/null +++ b/client/src/components/ui/sheet.tsx @@ -0,0 +1,101 @@ +import * as React from 'react' +import * as SheetPrimitive from '@radix-ui/react-dialog' + +import { cn } from '@/lib/utils' + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +type SheetSide = 'top' | 'bottom' | 'left' | 'right' + +interface SheetContentProps extends React.ComponentPropsWithoutRef { + side?: SheetSide +} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = 'bottom', className, children, ...props }, ref) => ( + + + + {children} + + Close + Close + + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = 'SheetHeader' + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = 'SheetFooter' + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/client/src/components/ui/tabs.tsx b/client/src/components/ui/tabs.tsx new file mode 100644 index 0000000..566dec5 --- /dev/null +++ b/client/src/components/ui/tabs.tsx @@ -0,0 +1,50 @@ +import * as TabsPrimitive from '@radix-ui/react-tabs' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/client/src/hooks/useHotspotFeed.ts b/client/src/hooks/useHotspotFeed.ts new file mode 100644 index 0000000..5d1d32c --- /dev/null +++ b/client/src/hooks/useHotspotFeed.ts @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { distanceInKm } from '@/lib/utils' +import type { ApiDensityCell, ApiPoint, ApiSnapshot, FeedStatus, LatLng } from '@/types/api' + +const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals' +const SNAPSHOT_LIMIT = 750 +const DEFAULT_REFRESH_MS = 7000 +const VISIBLE_RADIUS_KM = 1 + +interface UseHotspotFeedOptions { + autoRefreshMs?: number +} + +interface SubmitResult { + success: boolean +} + +export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) { + const [status, setStatus] = useState('loading') + const [errorMessage, setErrorMessage] = useState(null) + const [rawPoints, setRawPoints] = useState([]) + const [rawDensity, setRawDensity] = useState([]) + const [rawLatestByUser, setRawLatestByUser] = useState([]) + const [clientKey, setClientKey] = useState(null) + const [lastUpdated, setLastUpdated] = useState(null) + + const statusRef = useRef('loading') + const initialLoadRef = useRef(true) + + const setStatusSafe = useCallback((next: FeedStatus) => { + statusRef.current = next + setStatus(next) + }, []) + + const fetchSnapshot = useCallback( + async (options?: { silent?: boolean }) => { + const silent = options?.silent ?? false + const previousStatus = statusRef.current + const isInitial = initialLoadRef.current + + if (previousStatus !== 'posting') { + if (isInitial) { + setStatusSafe('loading') + } else if (!silent) { + setStatusSafe('refreshing') + } + } + + try { + const response = await fetch(`${API_BASE}?limit=${SNAPSHOT_LIMIT}`, { cache: 'no-store' }) + if (!response.ok) { + throw new Error('Unable to reach the hotspot feed.') + } + + const data: ApiSnapshot = await response.json() + setRawPoints(data.points ?? []) + setRawDensity(data.density ?? []) + setRawLatestByUser(data.latestByUser ?? []) + setClientKey(data.clientKey ?? null) + setLastUpdated(data.updatedAt ?? new Date().toISOString()) + setErrorMessage(null) + initialLoadRef.current = false + + const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle' + setStatusSafe(nextStatus) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error while loading hotspots.' + setErrorMessage(message) + if (initialLoadRef.current) { + setStatusSafe('error') + } else if (previousStatus !== 'posting') { + setStatusSafe('idle') + } + } + }, + [setStatusSafe], + ) + + useEffect(() => { + fetchSnapshot().catch(() => undefined) + if (!autoRefreshMs) { + return + } + + const interval = window.setInterval(() => { + fetchSnapshot({ silent: true }).catch(() => undefined) + }, autoRefreshMs) + + return () => { + window.clearInterval(interval) + } + }, [autoRefreshMs, fetchSnapshot]) + + const submitPoint = useCallback( + async (lat: number, lng: number): Promise => { + setStatusSafe('posting') + try { + const response = await fetch(API_BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lat, lng }), + }) + + if (!response.ok) { + const payload = await response.json().catch(() => null) + const message = payload?.message ?? 'Unable to store your signal.' + throw new Error(message) + } + + await fetchSnapshot({ silent: true }) + setStatusSafe('idle') + return { success: true } + } catch (error) { + const message = error instanceof Error ? error.message : 'Something went wrong while saving your signal.' + setErrorMessage(message) + setStatusSafe('error') + return { success: false } + } + }, + [fetchSnapshot, setStatusSafe], + ) + + const hasClientKey = Boolean(clientKey) + + const filterWithinRadius = useCallback( + (collection: T[], origin: LatLng | null) => { + if (!origin) { + return [] + } + return collection.filter((item) => distanceInKm(origin, item) <= VISIBLE_RADIUS_KM) + }, + [], + ) + + const selectVisibleDensity = useCallback( + (origin: LatLng | null) => filterWithinRadius(rawDensity, origin), + [filterWithinRadius, rawDensity], + ) + + const selectVisiblePoints = useCallback( + (origin: LatLng | null) => filterWithinRadius(rawPoints, origin), + [filterWithinRadius, rawPoints], + ) + + const selectVisibleLatestByUser = useCallback( + (origin: LatLng | null) => filterWithinRadius(rawLatestByUser, origin), + [filterWithinRadius, rawLatestByUser], + ) + + const myLatestPoint = useMemo(() => { + if (!hasClientKey) { + return null + } + return rawLatestByUser.find((point) => point.userKey === clientKey) ?? null + }, [clientKey, hasClientKey, rawLatestByUser]) + + return { + status, + errorMessage, + submitPoint, + fetchSnapshot, + rawDensity, + rawPoints, + rawLatestByUser, + clientKey, + lastUpdated, + hasClientKey, + selectVisibleDensity, + selectVisiblePoints, + selectVisibleLatestByUser, + myLatestPoint, + } +} diff --git a/client/src/hooks/useLeafletHeatmap.ts b/client/src/hooks/useLeafletHeatmap.ts new file mode 100644 index 0000000..f8de559 --- /dev/null +++ b/client/src/hooks/useLeafletHeatmap.ts @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useRef } from 'react' +import type { MutableRefObject } from 'react' +import L, { type LeafletMouseEvent, type Map as LeafletMap, type LayerGroup } from 'leaflet' +import 'leaflet.heat' + +import type { ApiDensityCell, LatLng } from '@/types/api' + +type HeatPoint = [number, number, number?] + +type LeafletHeatLayer = L.Layer & { + setLatLngs(points: HeatPoint[]): LeafletHeatLayer + getBounds?: () => L.LatLngBounds +} + +type LeafletWithHeat = typeof L & { + heatLayer?: (points: HeatPoint[], options?: Record) => LeafletHeatLayer +} + +interface UseLeafletHeatmapParams { + heatCells: ApiDensityCell[] + userLocation: LatLng | null + onRequestSpot?: (position: LatLng) => void +} + +interface UseLeafletHeatmapResult { + mapContainerRef: MutableRefObject + focusOn: (position: LatLng, zoom?: number) => void + fitToHeat: () => void + map: LeafletMap | null +} + +const INITIAL_VIEW: LatLng = { lat: 20, lng: 0 } +const DEFAULT_ZOOM = 3 + +export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: UseLeafletHeatmapParams): UseLeafletHeatmapResult { + const mapRef = useRef(null) + const heatLayerRef = useRef(null) + const userLayerRef = useRef(null) + const hasCenteredOnUserRef = useRef(false) + const mapContainerRef = useRef(null) + + const initialiseMap = useCallback(() => { + if (mapRef.current || !mapContainerRef.current) { + return + } + + const leaflet = L as LeafletWithHeat + const container = mapContainerRef.current + + const map = leaflet + .map(container, { + worldCopyJump: true, + minZoom: 2, + zoomControl: false, + attributionControl: false, + }) + .setView([INITIAL_VIEW.lat, INITIAL_VIEW.lng], DEFAULT_ZOOM) + + leaflet + .tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + crossOrigin: true, + maxZoom: 19, + }) + .addTo(map) + + const heatLayer = + typeof leaflet.heatLayer === 'function' + ? leaflet.heatLayer([], { + radius: 32, + blur: 24, + maxZoom: 12, + gradient: { + 0.2: '#38bdf8', + 0.4: '#0ea5e9', + 0.6: '#fbbf24', + 0.8: '#fb923c', + 1: '#ef4444', + }, + }) + : null + + const userLayer = leaflet.layerGroup().addTo(map) + + if (heatLayer) { + heatLayer.addTo(map) + } + + const handleClick = (event: LeafletMouseEvent) => { + const { lat, lng } = event.latlng + if (typeof lat === 'number' && typeof lng === 'number') { + onRequestSpot?.({ lat, lng }) + } + } + + map.on('click', handleClick) + + map.whenReady(() => { + requestAnimationFrame(() => { + map.invalidateSize() + }) + }) + + const onResize = () => { + requestAnimationFrame(() => { + map.invalidateSize() + }) + } + + window.addEventListener('resize', onResize) + + mapRef.current = map + heatLayerRef.current = heatLayer + userLayerRef.current = userLayer + + return () => { + map.off('click', handleClick) + window.removeEventListener('resize', onResize) + map.remove() + mapRef.current = null + heatLayerRef.current = null + userLayerRef.current = null + hasCenteredOnUserRef.current = false + } + }, [onRequestSpot]) + + useEffect(() => { + const cleanup = initialiseMap() + + return () => { + if (typeof cleanup === 'function') { + cleanup() + } + } + }, [initialiseMap]) + + useEffect(() => { + const heatLayer = heatLayerRef.current + if (!heatLayer) { + return + } + + if (!heatCells.length) { + heatLayer.setLatLngs([]) + return + } + + const maxIntensity = Math.max(...heatCells.map((cell) => cell.intensity)) || 1 + const heatPoints: HeatPoint[] = heatCells.map((cell) => [ + cell.lat, + cell.lng, + Math.max(0.2, cell.intensity / maxIntensity), + ]) + + heatLayer.setLatLngs(heatPoints) + }, [heatCells]) + + useEffect(() => { + const userLayer = userLayerRef.current + const map = mapRef.current + if (!userLayer || !map) { + return + } + + userLayer.clearLayers() + + if (!userLocation) { + return + } + + L.circleMarker([userLocation.lat, userLocation.lng], { + radius: 7, + color: '#38bdf8', + weight: 3, + opacity: 0.9, + fillColor: '#38bdf8', + fillOpacity: 0.35, + }).addTo(userLayer) + + if (!hasCenteredOnUserRef.current) { + map.setView([userLocation.lat, userLocation.lng], 13, { animate: true }) + hasCenteredOnUserRef.current = true + } + }, [userLocation]) + + const focusOn = useCallback((position: LatLng, zoom = 14) => { + const map = mapRef.current + if (!map) { + return + } + map.setView([position.lat, position.lng], zoom, { animate: true }) + }, []) + + const fitToHeat = useCallback(() => { + const map = mapRef.current + const heatLayer = heatLayerRef.current + if (!map || !heatLayer) { + return + } + const bounds = heatLayer.getBounds?.() + if (bounds && bounds.isValid()) { + map.fitBounds(bounds.pad(0.25), { animate: true }) + } + }, []) + + return { + mapContainerRef, + focusOn, + fitToHeat, + map: mapRef.current, + } +} diff --git a/client/src/hooks/useTheme.ts b/client/src/hooks/useTheme.ts new file mode 100644 index 0000000..8533712 --- /dev/null +++ b/client/src/hooks/useTheme.ts @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useState } from 'react' + +type Theme = 'light' | 'dark' + +const STORAGE_KEY = 'signalmap-theme' + +function applyTheme(theme: Theme) { + const root = document.documentElement + if (theme === 'dark') { + root.classList.add('dark') + } else { + root.classList.remove('dark') + } +} + +function getPreferredTheme(): Theme { + if (typeof window === 'undefined') { + return 'dark' + } + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored === 'light' || stored === 'dark') { + return stored + } + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + return prefersDark ? 'dark' : 'light' +} + +export function useTheme() { + const [theme, setTheme] = useState(() => getPreferredTheme()) + + useEffect(() => { + applyTheme(theme) + window.localStorage.setItem(STORAGE_KEY, theme) + }, [theme]) + + const toggleTheme = useCallback(() => { + setTheme((current) => (current === 'dark' ? 'light' : 'dark')) + }, []) + + return { + theme, + setTheme, + toggleTheme, + isDark: theme === 'dark', + } +} diff --git a/client/src/hooks/useUserLocation.ts b/client/src/hooks/useUserLocation.ts new file mode 100644 index 0000000..5fdb0ba --- /dev/null +++ b/client/src/hooks/useUserLocation.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { LatLng } from '@/types/api' + +function geolocationErrorMessage(error: GeolocationPositionError): string { + switch (error.code) { + case error.PERMISSION_DENIED: + return 'Location access denied. Enable it to view nearby pings.' + case error.POSITION_UNAVAILABLE: + return 'Unable to determine your position. Try again.' + case error.TIMEOUT: + return 'Timed out while fetching your location.' + default: + return 'Failed to retrieve your location.' + } +} + +export function useUserLocation() { + const [location, setLocation] = useState(null) + const [error, setError] = useState(null) + const [isRequesting, setIsRequesting] = useState(false) + const watchIdRef = useRef(null) + + const clearWatch = useCallback(() => { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + return + } + if (watchIdRef.current !== null) { + navigator.geolocation.clearWatch(watchIdRef.current) + watchIdRef.current = null + } + }, []) + + const start = useCallback(() => { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + setError('Geolocation is not supported in this browser.') + setIsRequesting(false) + return + } + + clearWatch() + setIsRequesting(true) + setError(null) + + navigator.geolocation.getCurrentPosition( + (position) => { + setLocation({ lat: position.coords.latitude, lng: position.coords.longitude }) + setIsRequesting(false) + setError(null) + }, + (geoError) => { + setError(geolocationErrorMessage(geoError)) + setIsRequesting(false) + }, + { enableHighAccuracy: true, timeout: 10000 }, + ) + + const watchId = navigator.geolocation.watchPosition( + (position) => { + setLocation({ lat: position.coords.latitude, lng: position.coords.longitude }) + setError(null) + setIsRequesting(false) + }, + (geoError) => { + setError(geolocationErrorMessage(geoError)) + setIsRequesting(false) + }, + { enableHighAccuracy: true, maximumAge: 15000, timeout: 10000 }, + ) + + watchIdRef.current = watchId + }, [clearWatch]) + + useEffect(() => { + start() + + return () => { + clearWatch() + } + }, [clearWatch, start]) + + return { + location, + error, + isRequesting, + refresh: start, + } +} diff --git a/client/src/index.css b/client/src/index.css index fce2dbc..16deda9 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -5,15 +5,32 @@ @tailwind utilities; @layer base { - html, - body, - #root { - height: 100%; + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 200 98% 39%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 200 98% 39%; + --accent-foreground: 210 40% 98%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 200 98% 39%; + --radius: 0.9rem; } - :root { + .dark { color-scheme: dark; - --background: 222.2 84% 4.9%; + --background: 224 47% 8%; --foreground: 210 40% 98%; --card: 222.2 84% 6.5%; --card-foreground: 210 40% 96%; @@ -23,52 +40,52 @@ --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 96%; - --muted: 223 50% 12%; + --muted: 223 47% 10%; --muted-foreground: 215 20.2% 65.1%; --accent: 199 89% 62%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; - --border: 221.6 30% 23%; - --input: 221.6 30% 23%; + --border: 217 33% 18%; + --input: 217 33% 18%; --ring: 199 89% 62%; - --radius: 0.8rem; - font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } - * { + *, + *::before, + *::after { @apply border-border; } body { - @apply min-h-screen bg-background bg-radial-signal text-foreground antialiased; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + @apply min-h-screen bg-background text-foreground antialiased; + background-image: radial-gradient(circle at top, hsla(var(--primary) / 0.1), transparent 45%), + radial-gradient(circle at bottom, hsla(var(--destructive) / 0.08), transparent 55%); } - p { - @apply leading-relaxed; + #root { + @apply min-h-screen; + } +} + +@layer components { + .leaflet-wrapper, + .leaflet-container { + height: 100%; + width: 100%; } .leaflet-container { font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + z-index: 0; } - .leaflet-tooltip { - background: rgba(15, 23, 42, 0.9); - border: 1px solid rgba(148, 163, 184, 0.35); - color: #e2e8f0; - border-radius: 0.75rem; - box-shadow: 0 15px 35px rgba(2, 6, 23, 0.45); - padding: 0.65rem 0.75rem; - } - - .leaflet-tooltip-top:before, - .leaflet-tooltip-bottom:before, - .leaflet-tooltip-left:before, - .leaflet-tooltip-right:before { - border-top-color: rgba(15, 23, 42, 0.9); - border-bottom-color: rgba(15, 23, 42, 0.9); - border-left-color: rgba(15, 23, 42, 0.9); - border-right-color: rgba(15, 23, 42, 0.9); + .leaflet-pane, + .leaflet-top, + .leaflet-bottom, + .leaflet-control-container { + z-index: 20; } } diff --git a/client/src/types/api.ts b/client/src/types/api.ts new file mode 100644 index 0000000..637334a --- /dev/null +++ b/client/src/types/api.ts @@ -0,0 +1,32 @@ +export type FeedStatus = 'loading' | 'idle' | 'error' | 'posting' | 'refreshing' + +export interface ApiPoint { + id: number + lat: number + lng: number + createdAt: string + userKey: string +} + +export interface ApiDensityCell { + lat: number + lng: number + intensity: number +} + +export interface ApiSnapshot { + clientKey?: string + points?: ApiPoint[] + density?: ApiDensityCell[] + latestByUser?: ApiPoint[] + totals?: { + points: number + contributors: number + } + updatedAt?: string +} + +export type LatLng = { + lat: number + lng: number +}