Refactor client UI with shadcn heatmap layout
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+622
-5
@@ -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",
|
||||
|
||||
+7
-1
@@ -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",
|
||||
|
||||
+239
-701
@@ -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<number, string>
|
||||
}
|
||||
|
||||
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<LeafletMap | null>(null)
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const heatLayerRef = useRef<LeafletHeatLayer | null>(null)
|
||||
const markersLayerRef = useRef<LayerGroup | null>(null)
|
||||
const zonesLayerRef = useRef<LayerGroup | null>(null)
|
||||
const userLayerRef = useRef<LayerGroup | null>(null)
|
||||
const locationWatchIdRef = useRef<number | null>(null)
|
||||
const statusRef = useRef<Status>('loading')
|
||||
const initialLoadRef = useRef(true)
|
||||
const hasCenteredOnUserRef = useRef(false)
|
||||
const [pendingSpot, setPendingSpot] = useState<LatLng | null>(null)
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
|
||||
const [isConfirming, setIsConfirming] = useState(false)
|
||||
|
||||
const [status, setStatus] = useState<Status>('loading')
|
||||
const [rawPoints, setRawPoints] = useState<ApiPoint[]>([])
|
||||
const [rawDensity, setRawDensity] = useState<ApiDensity[]>([])
|
||||
const [rawLatestByUser, setRawLatestByUser] = useState<ApiPoint[]>([])
|
||||
const [clientKey, setClientKey] = useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [userLocation, setUserLocation] = useState<LatLng | null>(null)
|
||||
const [locationError, setLocationError] = useState<string | null>(null)
|
||||
const [isRequestingLocation, setIsRequestingLocation] = useState<boolean>(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<string>()
|
||||
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<string, ApiPoint>()
|
||||
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 (
|
||||
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
||||
<header className="sticky top-0 z-10 border-b border-border/60 bg-background/70 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-4 px-6 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-semibold sm:text-2xl">SignalMap</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">
|
||||
Crowd-powered danger zones layered on Leaflet + OpenStreetMap.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={statusBadgeClass} aria-live="polite">
|
||||
<span className={cn('flex items-center gap-2', statusAccentClass)}>
|
||||
<span className="status-dot relative block h-2.5 w-2.5 rounded-full bg-[currentColor]" aria-hidden />
|
||||
{statusLabel}
|
||||
</span>
|
||||
</span>
|
||||
<Button variant="ghost" onClick={refreshNow} disabled={isLoading || status === 'refreshing' || isPosting}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={focusDangerZone} disabled={!visibleDangerZones.length}>
|
||||
Focus danger zone
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={focusMySignal} disabled={!myLatestPoint}>
|
||||
Locate me
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<AppHeader
|
||||
status={status}
|
||||
statusLabel={statusLabel}
|
||||
lastUpdatedLabel={lastUpdatedLabel}
|
||||
onRefresh={handleRefresh}
|
||||
onFocusHeat={handleFocusHeat}
|
||||
onLocateUser={handleLocateUser}
|
||||
onFocusMySignal={handleFocusMySignal}
|
||||
disableRefresh={isLoading || isRefreshing || isPosting}
|
||||
disableHeat={visibleDensity.length === 0}
|
||||
disableLocate={!hasLocation}
|
||||
disableMySignal={!myVisibleSignal}
|
||||
/>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-6 py-6 lg:flex-row">
|
||||
<section className="flex w-full flex-col gap-4 lg:max-w-sm">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Danger zone intel</CardTitle>
|
||||
<CardDescription>Highest intensity cells near you right now.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!hasLocation && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We need your location to reveal community alerts around you.
|
||||
</p>
|
||||
)}
|
||||
{hasLocation && visibleDangerZones.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No hotspots within {VISIBLE_RADIUS_KM}km yet. Tap anywhere on the map to raise the first signal.
|
||||
</p>
|
||||
)}
|
||||
{visibleDangerZones.map((zone, index) => (
|
||||
<div
|
||||
className="flex items-center justify-between rounded-lg border border-border/60 bg-muted/20 px-3 py-2"
|
||||
key={`${zone.lat}-${zone.lng}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-foreground">Hotspot #{index + 1}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatCoordinate(zone.lat)}, {formatCoordinate(zone.lng)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-orange-400">×{zone.intensity}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{hasLocation ? (
|
||||
<span>
|
||||
Heatmap max intensity: {heatMax || '—'} • Last sync {lastUpdatedLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span>Totals unavailable until we can access your position.</span>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Community feed</CardTitle>
|
||||
<CardDescription>
|
||||
Local reports: {localTotals.points} · Unique spotters: {localTotals.contributors}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!hasLocation && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allow location access to tune the feed to your area.
|
||||
</p>
|
||||
)}
|
||||
{hasLocation && recentActivity.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Waiting for the first signals nearby. Click the map to broadcast a hazard ping.
|
||||
</p>
|
||||
)}
|
||||
{recentActivity.map((item) => (
|
||||
<div
|
||||
className="flex flex-col gap-1 rounded-lg border border-border/60 bg-card/40 px-3 py-2"
|
||||
key={item.id}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatCoordinate(item.lat)}, {formatCoordinate(item.lng)}
|
||||
</span>
|
||||
<Badge variant={item.userKey === clientKey ? 'success' : 'muted'}>
|
||||
{item.userKey === clientKey ? 'You' : `User ${item.userKey}`}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{formatRelativeTime(item.createdAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</section>
|
||||
|
||||
<section className="flex w-full flex-1 flex-col gap-4">
|
||||
{status === 'error' && errorMessage && (
|
||||
<div className="flex items-center justify-between rounded-xl border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive shadow-lg shadow-destructive/20">
|
||||
<span>{errorMessage}</span>
|
||||
<Button variant="ghost" onClick={refreshNow} className="text-destructive hover:text-destructive">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex min-h-[480px] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-card/70 shadow-2xl shadow-black/40">
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className="h-full min-h-[480px] w-full"
|
||||
aria-label="Collaborative danger zone map"
|
||||
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-4 py-6 sm:px-6">
|
||||
<section className="flex flex-col gap-6 lg:flex-row">
|
||||
<div className="order-2 flex w-full flex-col gap-4 lg:order-1 lg:max-w-sm">
|
||||
<OverviewPanel
|
||||
nearbySignals={localTotals.points}
|
||||
uniqueContributors={localTotals.contributors}
|
||||
lastUpdatedLabel={lastUpdatedLabel}
|
||||
mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt) : null}
|
||||
errorMessage={errorMessage}
|
||||
onReport={handleManualReport}
|
||||
onRetry={handleRefresh}
|
||||
isPosting={isPosting || isConfirming}
|
||||
locationHint={locationHint}
|
||||
showLocationCta={showLocationCta}
|
||||
disableReport={!hasLocation}
|
||||
/>
|
||||
<HotspotStatsPanel
|
||||
hasLocation={hasLocation}
|
||||
radiusKm={RADIUS_KM}
|
||||
locationHint={locationHint}
|
||||
cells={dangerCells}
|
||||
/>
|
||||
<ActivityPanel items={recentActivity} emptyMessage="No recent signals within your area yet." />
|
||||
</div>
|
||||
|
||||
<div className="order-1 flex w-full flex-1 lg:order-2">
|
||||
<MapViewport
|
||||
containerRef={mapContainerRef}
|
||||
isPosting={isPosting || isConfirming}
|
||||
isLoading={isLoading}
|
||||
confirmationHint={isConfirmOpen ? 'Confirm the new signal in the dialog to send it.' : null}
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-4 left-4 right-4 mx-auto flex max-w-md flex-col gap-3 rounded-2xl border border-border/60 bg-background/90 p-4 text-xs text-muted-foreground shadow-xl shadow-black/50 backdrop-blur">
|
||||
<div className="flex items-center justify-between text-[0.8rem] text-foreground">
|
||||
<span className="font-semibold">Collaborative heatmap</span>
|
||||
<Badge variant="destructive">LIVE</Badge>
|
||||
</div>
|
||||
<p className="text-[0.8rem] leading-relaxed text-muted-foreground">
|
||||
Click anywhere to drop a signal. We blend every report into a shared danger zone heatmap focused on your
|
||||
surroundings.
|
||||
</p>
|
||||
<Separator className="bg-border/40" />
|
||||
<div className="flex flex-col gap-1 text-[0.75rem] text-muted-foreground">
|
||||
<span>Local signals: {localTotals.points} • Last sync {lastUpdatedLabel}</span>
|
||||
<span>{locationHint}</span>
|
||||
</div>
|
||||
{showLocationCta && (
|
||||
<div className="pointer-events-auto flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => startLocationWatch()}
|
||||
disabled={isRequestingLocation}
|
||||
>
|
||||
{isRequestingLocation ? 'Requesting location…' : 'Enable location'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<AlertDialog
|
||||
open={isConfirmOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setIsConfirmOpen(nextOpen)
|
||||
if (!nextOpen) {
|
||||
setPendingSpot(null)
|
||||
setIsConfirming(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm new signal</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You're about to publish a community alert at these coordinates. Double-check the spot before confirming.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/40 p-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Latitude</span>
|
||||
<span className="font-medium text-foreground">{confirmationLat}°</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Longitude</span>
|
||||
<span className="font-medium text-foreground">{confirmationLng}°</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Signals are visible to travellers within {RADIUS_KM}km and help the community stay aware of hotspots.
|
||||
</p>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isConfirming}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isDialogDisabled}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
handleConfirmSignal().catch(() => undefined)
|
||||
}}
|
||||
>
|
||||
{isConfirming ? 'Sending…' : 'Confirm signal'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<header className="sticky top-0 z-40 border-b border-border/60 bg-background/80 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-border/60 bg-primary/10 text-primary">
|
||||
<Flame className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-semibold sm:text-xl">SignalMap</span>
|
||||
<span className="text-xs text-muted-foreground sm:text-sm">Crowd signals around your route</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
'inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/60 px-3 py-1 text-xs font-medium uppercase tracking-wide'
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-2 ${isError ? 'text-destructive' : 'text-primary'}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="relative block h-2.5 w-2.5 rounded-full bg-current">
|
||||
<span className="absolute inset-[-0.35rem] rounded-full border border-current opacity-40 animate-status-pulse" />
|
||||
</span>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase text-muted-foreground">{lastUpdatedLabel}</span>
|
||||
</Badge>
|
||||
<Button variant="ghost" size="icon" onClick={onRefresh} disabled={disableRefresh} aria-label="Refresh now">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="icon" onClick={onFocusHeat} disabled={disableHeat} aria-label="Focus heatmap">
|
||||
<Focus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onLocateUser} disabled={disableLocate} aria-label="Locate me">
|
||||
<LocateFixed className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onFocusMySignal} disabled={disableMySignal} aria-label="My last signal">
|
||||
<MapPin className="h-4 w-4" />
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
className="rounded-full border border-border/60 bg-background/80 backdrop-blur"
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { MutableRefObject } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MapViewportProps {
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>
|
||||
isPosting: boolean
|
||||
isLoading: boolean
|
||||
confirmationHint?: string | null
|
||||
}
|
||||
|
||||
export function MapViewport({ containerRef, isPosting, isLoading, confirmationHint }: MapViewportProps) {
|
||||
return (
|
||||
<div className="relative min-h-[360px] flex-1 overflow-hidden rounded-3xl border border-border/50 bg-muted/40 shadow-inner">
|
||||
<div ref={containerRef} className={cn('absolute inset-0 z-0', 'leaflet-wrapper')} />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background/70 to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-24 bg-gradient-to-t from-background/70 to-transparent" />
|
||||
{(isPosting || isLoading) && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||
<span className="rounded-full border border-border/60 bg-background/80 px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground backdrop-blur">
|
||||
{isPosting ? 'Sending your signal…' : 'Syncing map…'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{confirmationHint && (
|
||||
<div className="pointer-events-none absolute bottom-6 left-1/2 z-20 w-[90%] max-w-sm -translate-x-1/2 rounded-full border border-border/70 bg-background/90 px-4 py-2 text-xs text-muted-foreground shadow">
|
||||
{confirmationHint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
Live community pings
|
||||
</CardTitle>
|
||||
<CardDescription>Latest activity reported by nearby contributors.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{items.length === 0 && <p className="text-sm text-muted-foreground">{emptyMessage}</p>}
|
||||
{items.length > 0 && (
|
||||
<ScrollArea className="max-h-[280px] pr-2">
|
||||
<ul className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="rounded-2xl border border-border/60 bg-muted/50 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-foreground">{item.title}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.subtitle}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-border/60 text-[10px] uppercase text-muted-foreground">
|
||||
{item.timestampLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{item.distanceLabel}</span>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-2 text-xs" onClick={item.onFocus}>
|
||||
View
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Flame className="h-5 w-5 text-primary" />
|
||||
Danger zone intel
|
||||
</CardTitle>
|
||||
<CardDescription>Highest intensity heat within {radiusKm}km.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!hasLocation && <p className="text-sm text-muted-foreground">{locationHint}</p>}
|
||||
{hasLocation && cells.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No active hotspots nearby. Tap the map to log a new signal.</p>
|
||||
)}
|
||||
{cells.length > 0 && (
|
||||
<ScrollArea className="max-h-[280px] pr-2">
|
||||
<ul className="space-y-3">
|
||||
{cells.map((cell) => (
|
||||
<li key={cell.id} className="rounded-2xl border border-border/60 bg-muted/50 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-foreground">{cell.title}</span>
|
||||
<span className="text-xs text-muted-foreground">{cell.subtitle}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-primary/40 text-xs text-primary">
|
||||
{cell.intensity.toFixed(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 w-full justify-center gap-2 text-xs"
|
||||
onClick={cell.onFocus}
|
||||
>
|
||||
<MapPin className="h-3.5 w-3.5" /> Focus
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radio className="h-5 w-5 text-primary" />
|
||||
Nearby coverage
|
||||
</CardTitle>
|
||||
<CardDescription>Signals refresh automatically every few seconds.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/50 p-3">
|
||||
<span className="text-xs uppercase text-muted-foreground">Signals</span>
|
||||
<p className="text-xl font-semibold">{nearbySignals}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/50 p-3">
|
||||
<span className="text-xs uppercase text-muted-foreground">Contributors</span>
|
||||
<p className="text-xl font-semibold">{uniqueContributors}</p>
|
||||
</div>
|
||||
</div>
|
||||
{mySignalLabel && (
|
||||
<Badge variant="secondary" className="w-full justify-center rounded-full py-2 text-xs uppercase">
|
||||
Your last signal {mySignalLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{errorMessage ? (
|
||||
<div className="flex items-start gap-3 rounded-2xl border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4" />
|
||||
<div className="space-y-2">
|
||||
<p>{errorMessage}</p>
|
||||
<Button variant="outline" size="sm" className="text-xs" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{locationHint}</p>
|
||||
)}
|
||||
<Button className="w-full" onClick={onReport} disabled={isPosting || disableReport}>
|
||||
{isPosting ? 'Sending…' : disableReport ? 'Waiting for location…' : 'Drop a signal manually'}
|
||||
</Button>
|
||||
{showLocationCta && !errorMessage && (
|
||||
<p className="text-xs text-muted-foreground">Allow location permissions to personalise the feed.</p>
|
||||
)}
|
||||
<p className="text-[11px] uppercase text-muted-foreground">Last synced {lastUpdatedLabel}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border border-border/70 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
|
||||
)
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn('inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn('inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50 sm:mr-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -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<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2 border-t border-t-transparent p-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border/60" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -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<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-40 bg-background/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
type SheetSide = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> {
|
||||
side?: SheetSide
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = 'bottom', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed z-50 grid gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
side === 'bottom' && 'inset-x-0 bottom-0 rounded-t-2xl border-t',
|
||||
side === 'top' && 'inset-x-0 top-0 rounded-b-2xl border-b',
|
||||
side === 'left' && 'inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'right' && 'inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
className,
|
||||
)}
|
||||
data-side={side}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-6 top-6 rounded-full border border-border/50 bg-background/60 px-3 py-1 text-xs font-medium text-muted-foreground transition hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background">
|
||||
Close
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
SheetHeader.displayName = 'SheetHeader'
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
|
||||
)
|
||||
SheetFooter.displayName = 'SheetFooter'
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -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<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('inline-flex h-10 items-center justify-center rounded-full bg-muted p-1 text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex min-w-[120px] items-center justify-center whitespace-nowrap rounded-full px-4 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=inactive]:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -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<FeedStatus>('loading')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [rawPoints, setRawPoints] = useState<ApiPoint[]>([])
|
||||
const [rawDensity, setRawDensity] = useState<ApiDensityCell[]>([])
|
||||
const [rawLatestByUser, setRawLatestByUser] = useState<ApiPoint[]>([])
|
||||
const [clientKey, setClientKey] = useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null)
|
||||
|
||||
const statusRef = useRef<FeedStatus>('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<SubmitResult> => {
|
||||
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(
|
||||
<T extends LatLng>(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,
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>) => LeafletHeatLayer
|
||||
}
|
||||
|
||||
interface UseLeafletHeatmapParams {
|
||||
heatCells: ApiDensityCell[]
|
||||
userLocation: LatLng | null
|
||||
onRequestSpot?: (position: LatLng) => void
|
||||
}
|
||||
|
||||
interface UseLeafletHeatmapResult {
|
||||
mapContainerRef: MutableRefObject<HTMLDivElement | null>
|
||||
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<LeafletMap | null>(null)
|
||||
const heatLayerRef = useRef<LeafletHeatLayer | null>(null)
|
||||
const userLayerRef = useRef<LayerGroup | null>(null)
|
||||
const hasCenteredOnUserRef = useRef(false)
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(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,
|
||||
}
|
||||
}
|
||||
@@ -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<Theme>(() => 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',
|
||||
}
|
||||
}
|
||||
@@ -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<LatLng | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isRequesting, setIsRequesting] = useState<boolean>(false)
|
||||
const watchIdRef = useRef<number | null>(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,
|
||||
}
|
||||
}
|
||||
+49
-32
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user