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",
|
"name": "client",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"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-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.heat": "^0.2.0",
|
"leaflet.heat": "^0.2.0",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
@@ -726,6 +732,72 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
"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": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-beta.41",
|
"version": "1.0.0-beta.41",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.41.tgz",
|
"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",
|
"version": "19.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz",
|
||||||
"integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==",
|
"integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
@@ -1527,6 +1998,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"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": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.21",
|
"version": "10.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||||
@@ -1894,6 +2377,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@@ -2321,6 +2810,15 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||||
@@ -2982,6 +3480,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -3510,6 +4017,75 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -4034,9 +4610,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
@@ -4137,6 +4711,49 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
+7
-1
@@ -10,14 +10,20 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.heat": "^0.2.0",
|
"leaflet.heat": "^0.2.0",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
|
|||||||
+239
-701
@@ -1,76 +1,36 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import L, { type LayerGroup, type LeafletMouseEvent, type Map as LeafletMap } from 'leaflet'
|
|
||||||
import 'leaflet.heat'
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { AppHeader } from '@/components/layout/AppHeader'
|
||||||
import { Button } from '@/components/ui/button'
|
import { ActivityPanel } from '@/components/panels/ActivityPanel'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { HotspotStatsPanel } from '@/components/panels/HotspotStatsPanel'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { OverviewPanel } from '@/components/panels/OverviewPanel'
|
||||||
import { cn, distanceInKm, formatCoordinate, formatRelativeTime } from '@/lib/utils'
|
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 RADIUS_KM = 1
|
||||||
const VISIBLE_RADIUS_KM = 5
|
|
||||||
|
|
||||||
type Status = 'loading' | 'idle' | 'error' | 'posting' | 'refreshing'
|
function getStatusLabel(status: FeedStatus): string {
|
||||||
|
|
||||||
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 {
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'loading':
|
case 'loading':
|
||||||
return 'Syncing map'
|
return 'Syncing map'
|
||||||
case 'posting':
|
case 'posting':
|
||||||
return 'Sending your signal'
|
return 'Sending signal'
|
||||||
case 'refreshing':
|
case 'refreshing':
|
||||||
return 'Updating hotspots'
|
return 'Updating heat'
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'Offline'
|
return 'Offline'
|
||||||
default:
|
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() {
|
export default function App() {
|
||||||
const mapRef = useRef<LeafletMap | null>(null)
|
const [pendingSpot, setPendingSpot] = useState<LatLng | null>(null)
|
||||||
const mapContainerRef = useRef<HTMLDivElement | null>(null)
|
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
|
||||||
const heatLayerRef = useRef<LeafletHeatLayer | null>(null)
|
const [isConfirming, setIsConfirming] = useState(false)
|
||||||
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 [status, setStatus] = useState<Status>('loading')
|
const {
|
||||||
const [rawPoints, setRawPoints] = useState<ApiPoint[]>([])
|
status,
|
||||||
const [rawDensity, setRawDensity] = useState<ApiDensity[]>([])
|
errorMessage,
|
||||||
const [rawLatestByUser, setRawLatestByUser] = useState<ApiPoint[]>([])
|
submitPoint,
|
||||||
const [clientKey, setClientKey] = useState<string | null>(null)
|
fetchSnapshot,
|
||||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null)
|
selectVisibleDensity,
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
selectVisiblePoints,
|
||||||
const [userLocation, setUserLocation] = useState<LatLng | null>(null)
|
selectVisibleLatestByUser,
|
||||||
const [locationError, setLocationError] = useState<string | null>(null)
|
myLatestPoint,
|
||||||
const [isRequestingLocation, setIsRequestingLocation] = useState<boolean>(false)
|
lastUpdated,
|
||||||
|
} = useHotspotFeed()
|
||||||
|
|
||||||
const setStatusSafe = useCallback((next: Status) => {
|
const { location: userLocation, error: locationError, isRequesting: isRequestingLocation } = useUserLocation()
|
||||||
statusRef.current = next
|
|
||||||
setStatus(next)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const startLocationWatch = useCallback(() => {
|
const visibleDensity = useMemo(
|
||||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
() => selectVisibleDensity(userLocation ?? null),
|
||||||
setLocationError('Geolocation is not supported in this browser.')
|
[selectVisibleDensity, userLocation],
|
||||||
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 submitPoint = useCallback(
|
const visiblePoints = useMemo(
|
||||||
async (lat: number, lng: number) => {
|
() => selectVisiblePoints(userLocation ?? null),
|
||||||
setStatusSafe('posting')
|
[selectVisiblePoints, userLocation],
|
||||||
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 handleMapClick = useCallback(
|
const visibleLatestByUser = useMemo(
|
||||||
({ lat, lng }: LatLng) => {
|
() => selectVisibleLatestByUser(userLocation ?? null),
|
||||||
submitPoint(lat, lng).catch(() => {})
|
[selectVisibleLatestByUser, userLocation],
|
||||||
},
|
|
||||||
[submitPoint],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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 localTotals = useMemo(() => {
|
||||||
const uniqueUsers = new Set<string>()
|
const uniqueUsers = new Set<string>()
|
||||||
visibleLatestByUser.forEach((point) => uniqueUsers.add(point.userKey))
|
visibleLatestByUser.forEach((point) => uniqueUsers.add(point.userKey))
|
||||||
|
return { points: visiblePoints.length, contributors: uniqueUsers.size }
|
||||||
return {
|
|
||||||
points: visiblePoints.length,
|
|
||||||
contributors: uniqueUsers.size,
|
|
||||||
}
|
|
||||||
}, [visibleLatestByUser, visiblePoints])
|
}, [visibleLatestByUser, visiblePoints])
|
||||||
|
|
||||||
useEffect(() => {
|
const myVisibleSignal = useMemo(() => {
|
||||||
const heatLayer = heatLayerRef.current
|
if (!myLatestPoint) {
|
||||||
if (!heatLayer) {
|
return null
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
if (userLocation && distanceInKm(userLocation, myLatestPoint) > RADIUS_KM) {
|
||||||
if (!visibleDensity.length) {
|
return null
|
||||||
heatLayer.setLatLngs([])
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
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 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 lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated) : 'never'
|
||||||
const isLoading = status === 'loading'
|
const isLoading = status === 'loading'
|
||||||
const isPosting = status === 'posting'
|
const isPosting = status === 'posting'
|
||||||
const heatMax = visibleDangerZones[0]?.intensity ?? 0
|
const isRefreshing = status === 'refreshing'
|
||||||
const hasLocation = Boolean(userLocation)
|
const hasLocation = Boolean(userLocation)
|
||||||
const myLatestPoint = useMemo(() => {
|
const showLocationCta = !hasLocation || Boolean(locationError)
|
||||||
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 locationHint = locationError
|
const locationHint = locationError
|
||||||
? locationError
|
? locationError
|
||||||
: hasLocation
|
: hasLocation
|
||||||
? `Showing reports within ${VISIBLE_RADIUS_KM}km of you.`
|
? `Showing reports within ${RADIUS_KM}km of you.`
|
||||||
: isRequestingLocation
|
: isRequestingLocation
|
||||||
? 'Fetching your location...'
|
? 'Fetching your location…'
|
||||||
: 'Allow location access to view nearby danger pings.'
|
: '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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
<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">
|
<AppHeader
|
||||||
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-4 px-6 py-4">
|
status={status}
|
||||||
<div className="flex flex-col gap-1">
|
statusLabel={statusLabel}
|
||||||
<h1 className="text-xl font-semibold sm:text-2xl">SignalMap</h1>
|
lastUpdatedLabel={lastUpdatedLabel}
|
||||||
<p className="text-sm text-muted-foreground sm:text-base">
|
onRefresh={handleRefresh}
|
||||||
Crowd-powered danger zones layered on Leaflet + OpenStreetMap.
|
onFocusHeat={handleFocusHeat}
|
||||||
</p>
|
onLocateUser={handleLocateUser}
|
||||||
</div>
|
onFocusMySignal={handleFocusMySignal}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
disableRefresh={isLoading || isRefreshing || isPosting}
|
||||||
<span className={statusBadgeClass} aria-live="polite">
|
disableHeat={visibleDensity.length === 0}
|
||||||
<span className={cn('flex items-center gap-2', statusAccentClass)}>
|
disableLocate={!hasLocation}
|
||||||
<span className="status-dot relative block h-2.5 w-2.5 rounded-full bg-[currentColor]" aria-hidden />
|
disableMySignal={!myVisibleSignal}
|
||||||
{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>
|
|
||||||
|
|
||||||
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-6 py-6 lg:flex-row">
|
<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 w-full flex-col gap-4 lg:max-w-sm">
|
<section className="flex flex-col gap-6 lg:flex-row">
|
||||||
<Card>
|
<div className="order-2 flex w-full flex-col gap-4 lg:order-1 lg:max-w-sm">
|
||||||
<CardHeader>
|
<OverviewPanel
|
||||||
<CardTitle>Danger zone intel</CardTitle>
|
nearbySignals={localTotals.points}
|
||||||
<CardDescription>Highest intensity cells near you right now.</CardDescription>
|
uniqueContributors={localTotals.contributors}
|
||||||
</CardHeader>
|
lastUpdatedLabel={lastUpdatedLabel}
|
||||||
<CardContent className="space-y-3">
|
mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt) : null}
|
||||||
{!hasLocation && (
|
errorMessage={errorMessage}
|
||||||
<p className="text-sm text-muted-foreground">
|
onReport={handleManualReport}
|
||||||
We need your location to reveal community alerts around you.
|
onRetry={handleRefresh}
|
||||||
</p>
|
isPosting={isPosting || isConfirming}
|
||||||
)}
|
locationHint={locationHint}
|
||||||
{hasLocation && visibleDangerZones.length === 0 && (
|
showLocationCta={showLocationCta}
|
||||||
<p className="text-sm text-muted-foreground">
|
disableReport={!hasLocation}
|
||||||
No hotspots within {VISIBLE_RADIUS_KM}km yet. Tap anywhere on the map to raise the first signal.
|
/>
|
||||||
</p>
|
<HotspotStatsPanel
|
||||||
)}
|
hasLocation={hasLocation}
|
||||||
{visibleDangerZones.map((zone, index) => (
|
radiusKm={RADIUS_KM}
|
||||||
<div
|
locationHint={locationHint}
|
||||||
className="flex items-center justify-between rounded-lg border border-border/60 bg-muted/20 px-3 py-2"
|
cells={dangerCells}
|
||||||
key={`${zone.lat}-${zone.lng}`}
|
/>
|
||||||
>
|
<ActivityPanel items={recentActivity} emptyMessage="No recent signals within your area yet." />
|
||||||
<div className="flex flex-col">
|
</div>
|
||||||
<span className="text-sm font-semibold text-foreground">Hotspot #{index + 1}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="order-1 flex w-full flex-1 lg:order-2">
|
||||||
{formatCoordinate(zone.lat)}, {formatCoordinate(zone.lng)}
|
<MapViewport
|
||||||
</span>
|
containerRef={mapContainerRef}
|
||||||
</div>
|
isPosting={isPosting || isConfirming}
|
||||||
<span className="text-sm font-semibold text-orange-400">×{zone.intensity}</span>
|
isLoading={isLoading}
|
||||||
</div>
|
confirmationHint={isConfirmOpen ? 'Confirm the new signal in the dialog to send it.' : null}
|
||||||
))}
|
|
||||||
</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"
|
|
||||||
/>
|
/>
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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>
|
</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;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html,
|
:root {
|
||||||
body,
|
--background: 0 0% 100%;
|
||||||
#root {
|
--foreground: 222.2 84% 4.9%;
|
||||||
height: 100%;
|
--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;
|
color-scheme: dark;
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 224 47% 8%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 210 40% 98%;
|
||||||
--card: 222.2 84% 6.5%;
|
--card: 222.2 84% 6.5%;
|
||||||
--card-foreground: 210 40% 96%;
|
--card-foreground: 210 40% 96%;
|
||||||
@@ -23,52 +40,52 @@
|
|||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
--secondary-foreground: 210 40% 96%;
|
--secondary-foreground: 210 40% 96%;
|
||||||
--muted: 223 50% 12%;
|
--muted: 223 47% 10%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
--accent: 199 89% 62%;
|
--accent: 199 89% 62%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 221.6 30% 23%;
|
--border: 217 33% 18%;
|
||||||
--input: 221.6 30% 23%;
|
--input: 217 33% 18%;
|
||||||
--ring: 199 89% 62%;
|
--ring: 199 89% 62%;
|
||||||
--radius: 0.8rem;
|
|
||||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
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 {
|
#root {
|
||||||
@apply leading-relaxed;
|
@apply min-h-screen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.leaflet-wrapper,
|
||||||
|
.leaflet-container {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-container {
|
.leaflet-container {
|
||||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-tooltip {
|
.leaflet-pane,
|
||||||
background: rgba(15, 23, 42, 0.9);
|
.leaflet-top,
|
||||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
.leaflet-bottom,
|
||||||
color: #e2e8f0;
|
.leaflet-control-container {
|
||||||
border-radius: 0.75rem;
|
z-index: 20;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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