Merge pull request #3 from bernard-ng/codex/fix-ui-and-organize-client-code

Refactor client UI with shadcn components
This commit is contained in:
Bernard Ngandu
2025-10-10 10:39:24 +02:00
committed by GitHub
27 changed files with 2636 additions and 761 deletions
+16
View File
@@ -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"
}
}
+709 -6
View File
@@ -8,14 +8,22 @@
"name": "client",
"version": "0.0.0",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.5.3",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1"
"react-i18next": "^16.0.0",
"tailwind-merge": "^3.3.1",
"tslib": "^2.8.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@@ -285,6 +293,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -726,6 +743,72 @@
"node": ">=14"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -741,6 +824,290 @@
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@@ -759,6 +1126,121 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-beta.41",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.41.tgz",
@@ -1115,7 +1597,7 @@
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -1527,6 +2009,18 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@@ -1894,6 +2388,12 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -2321,6 +2821,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@@ -2424,6 +2933,46 @@
"node": ">= 0.4"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "25.5.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.5.3.tgz",
"integrity": "sha512-joFqorDeQ6YpIXni944upwnuHBf5IoPMuqAchGVeQLdWC2JOjxgM9V8UGLhNIIH/Q8QleRxIi0BSRQehSrDLcg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2982,6 +3531,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.545.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz",
"integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3500,6 +4058,32 @@
"react": "^19.2.0"
}
},
"node_modules/react-i18next": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.0.0.tgz",
"integrity": "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 25.5.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3510,6 +4094,75 @@
"node": ">=0.10.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
"integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4034,9 +4687,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -4055,7 +4706,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -4137,6 +4788,49 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -4252,6 +4946,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+9 -1
View File
@@ -10,14 +10,22 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.5.3",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1"
"react-i18next": "^16.0.0",
"tailwind-merge": "^3.3.1",
"tslib": "^2.8.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
+256 -705
View File
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
import { Flame, Focus, LocateFixed, MapPin, RefreshCw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { LanguageToggle } from '@/components/layout/LanguageToggle'
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 { t } = useTranslation()
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">{t('app.name')}</span>
<span className="text-xs text-muted-foreground sm:text-sm">{t('app.tagline')}</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">
{t('header.badge.updated', { time: lastUpdatedLabel })}
</span>
</Badge>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={disableRefresh}
aria-label={t('header.actions.refresh')}
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon"
onClick={onFocusHeat}
disabled={disableHeat}
aria-label={t('header.actions.focusHeat')}
>
<Focus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onLocateUser}
disabled={disableLocate}
aria-label={t('header.actions.locate')}
>
<LocateFixed className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onFocusMySignal}
disabled={disableMySignal}
aria-label={t('header.actions.mySignal')}
>
<MapPin className="h-4 w-4" />
</Button>
<LanguageToggle />
<ThemeToggle />
</div>
</div>
</header>
)
}
@@ -0,0 +1,28 @@
import { Globe } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
export function LanguageToggle() {
const { i18n, t } = useTranslation()
const current = i18n.language === 'fr' ? 'fr' : 'en'
const next = current === 'en' ? 'fr' : 'en'
const nextLabel = t(next === 'en' ? 'language.english' : 'language.french')
return (
<Button
variant="ghost"
size="sm"
onClick={() => {
i18n.changeLanguage(next).catch(() => undefined)
}}
aria-label={t('common.aria.language', { language: nextLabel })}
className="h-9 rounded-full border border-border/60 bg-background/80 px-3 backdrop-blur"
>
<span className="sr-only">{t('language.label')}</span>
<Globe className="h-4 w-4" />
<span className="ml-2 text-xs font-semibold uppercase text-muted-foreground">{current.toUpperCase()}</span>
</Button>
)
}
@@ -0,0 +1,22 @@
import { Moon, Sun } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { useTheme } from '@/hooks/useTheme'
export function ThemeToggle() {
const { toggleTheme, isDark } = useTheme()
const { t } = useTranslation()
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label={isDark ? t('common.aria.theme.light') : t('common.aria.theme.dark')}
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>
)
}
+35
View File
@@ -0,0 +1,35 @@
import type { MutableRefObject } from 'react'
import { useTranslation } from 'react-i18next'
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) {
const { t } = useTranslation()
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 ? t('map.posting') : t('map.loading')}
</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-sm">
{confirmationHint}
</div>
)}
</div>
)
}
@@ -0,0 +1,66 @@
import { Activity, ArrowRight } from 'lucide-react'
import { useTranslation } from 'react-i18next'
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) {
const { t } = useTranslation()
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" />
{t('activity.title')}
</CardTitle>
<CardDescription>{t('activity.description')}</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}>
{t('activity.view')}
<ArrowRight className="h-3.5 w-3.5" />
</Button>
</div>
</li>
))}
</ul>
</ScrollArea>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,71 @@
import { Flame, MapPin } from 'lucide-react'
import { useTranslation } from 'react-i18next'
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) {
const { t } = useTranslation()
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" />
{t('hotspots.title')}
</CardTitle>
<CardDescription>{t('hotspots.description', { radius: radiusKm })}</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">{t('hotspots.empty')}</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" /> {t('hotspots.focus')}
</Button>
</li>
))}
</ul>
</ScrollArea>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,91 @@
import { AlertCircle, Radio } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import type { FeedError } from '@/hooks/useHotspotFeed'
interface OverviewPanelProps {
nearbySignals: number
uniqueContributors: number
lastUpdatedLabel: string
mySignalLabel: string | null
error: FeedError | null
onReport: () => void
onRetry: () => void
isPosting: boolean
locationHint: string
showLocationCta: boolean
disableReport: boolean
}
export function OverviewPanel({
nearbySignals,
uniqueContributors,
lastUpdatedLabel,
mySignalLabel,
error,
onReport,
onRetry,
isPosting,
locationHint,
showLocationCta,
disableReport,
}: OverviewPanelProps) {
const { t } = useTranslation()
const errorMessage = error ? t(error.key, error.values) : null
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Radio className="h-5 w-5 text-primary" />
{t('overview.title')}
</CardTitle>
<CardDescription>{t('overview.description')}</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">{t('overview.stats.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">{t('overview.stats.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">
{t('overview.badge', { time: 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}>
{t('overview.error.action')}
</Button>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">{locationHint}</p>
)}
<Button className="w-full" onClick={onReport} disabled={isPosting || disableReport}>
{isPosting
? t('overview.cta.sending')
: disableReport
? t('overview.cta.waiting')
: t('overview.cta.send')}
</Button>
{showLocationCta && !errorMessage && (
<p className="text-xs text-muted-foreground">{t('overview.locationPermission')}</p>
)}
<p className="text-[11px] uppercase text-muted-foreground">{t('overview.lastSynced', { time: lastUpdatedLabel })}</p>
</CardContent>
</Card>
)
}
+109
View File
@@ -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-sm 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,
}
+1 -1
View File
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
<div
ref={ref}
className={cn(
'rounded-xl border border-border/60 bg-card/80 text-card-foreground shadow-xl shadow-black/30 backdrop-blur',
'rounded-xl border border-border/60 bg-card/80 text-card-foreground shadow-sm backdrop-blur',
className,
)}
{...props}
+40
View File
@@ -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 }
+106
View File
@@ -0,0 +1,106 @@
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { useTranslation } from 'react-i18next'
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) => {
const { t } = useTranslation()
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 grid gap-4 bg-background p-6 shadow-sm 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">
{t('common.actions.close')}
<span className="sr-only">{t('common.aria.sheet.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,
}
+50
View File
@@ -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 }
+191
View File
@@ -0,0 +1,191 @@
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 interface FeedError {
key: string
values?: Record<string, unknown>
}
export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) {
const [status, setStatus] = useState<FeedStatus>('loading')
const [error, setError] = useState<FeedError | 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('feed-unavailable')
}
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())
setError(null)
initialLoadRef.current = false
const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle'
setStatusSafe(nextStatus)
} catch (error) {
const message = error instanceof Error ? error.message : null
const key = message === 'feed-unavailable' ? 'errors.feedUnavailable' : 'errors.feedUnknown'
setError({ key })
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 as string | undefined
if (message) {
setError({ key: 'errors.submitWithReason', values: { message } })
} else {
setError({ key: 'errors.submitUnavailable' })
}
setStatusSafe('error')
return { success: false }
}
await fetchSnapshot({ silent: true })
setError(null)
setStatusSafe('idle')
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : null
if (message) {
setError({ key: 'errors.submitWithReason', values: { message } })
} else {
setError({ key: 'errors.submitUnknown' })
}
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,
error,
submitPoint,
fetchSnapshot,
rawDensity,
rawPoints,
rawLatestByUser,
clientKey,
lastUpdated,
hasClientKey,
selectVisibleDensity,
selectVisiblePoints,
selectVisibleLatestByUser,
myLatestPoint,
}
}
+212
View File
@@ -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,
}
}
+46
View File
@@ -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',
}
}
+88
View File
@@ -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.error.permissionDenied'
case error.POSITION_UNAVAILABLE:
return 'location.error.unavailable'
case error.TIMEOUT:
return 'location.error.timeout'
default:
return 'location.error.generic'
}
}
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('location.error.unsupported')
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
View File
@@ -5,15 +5,32 @@
@tailwind utilities;
@layer base {
html,
body,
#root {
height: 100%;
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 200 98% 39%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 200 98% 39%;
--accent-foreground: 210 40% 98%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 200 98% 39%;
--radius: 0.9rem;
}
:root {
.dark {
color-scheme: dark;
--background: 222.2 84% 4.9%;
--background: 224 47% 8%;
--foreground: 210 40% 98%;
--card: 222.2 84% 6.5%;
--card-foreground: 210 40% 96%;
@@ -23,52 +40,52 @@
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 96%;
--muted: 223 50% 12%;
--muted: 223 47% 10%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 199 89% 62%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 221.6 30% 23%;
--input: 221.6 30% 23%;
--border: 217 33% 18%;
--input: 217 33% 18%;
--ring: 199 89% 62%;
--radius: 0.8rem;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
* {
*,
*::before,
*::after {
@apply border-border;
}
body {
@apply min-h-screen bg-background bg-radial-signal text-foreground antialiased;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
@apply min-h-screen bg-background text-foreground antialiased;
background-image: radial-gradient(circle at top, hsla(var(--primary) / 0.1), transparent 45%),
radial-gradient(circle at bottom, hsla(var(--destructive) / 0.08), transparent 55%);
}
p {
@apply leading-relaxed;
#root {
@apply min-h-screen;
}
}
@layer components {
.leaflet-wrapper,
.leaflet-container {
height: 100%;
width: 100%;
}
.leaflet-container {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
z-index: 0;
}
.leaflet-tooltip {
background: rgba(15, 23, 42, 0.9);
border: 1px solid rgba(148, 163, 184, 0.35);
color: #e2e8f0;
border-radius: 0.75rem;
box-shadow: 0 15px 35px rgba(2, 6, 23, 0.45);
padding: 0.65rem 0.75rem;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
border-top-color: rgba(15, 23, 42, 0.9);
border-bottom-color: rgba(15, 23, 42, 0.9);
border-left-color: rgba(15, 23, 42, 0.9);
border-right-color: rgba(15, 23, 42, 0.9);
.leaflet-pane,
.leaflet-top,
.leaflet-bottom,
.leaflet-control-container {
z-index: 20;
}
}
+27
View File
@@ -0,0 +1,27 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import enCommon from '@/locales/en/common.json'
import frCommon from '@/locales/fr/common.json'
const browserLanguage =
typeof window !== 'undefined' ? window.navigator.language.split('-')[0]?.toLowerCase() : 'en'
i18n
.use(initReactI18next)
.init({
resources: {
en: { common: enCommon },
fr: { common: frCommon },
},
lng: browserLanguage === 'fr' ? 'fr' : 'en',
fallbackLng: 'en',
defaultNS: 'common',
interpolation: {
escapeValue: false,
},
})
.catch(() => undefined)
export { i18n }
+12 -10
View File
@@ -10,46 +10,48 @@ export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
export function formatCoordinate(value: number): string {
const formatter = new Intl.NumberFormat('en-US', {
export function formatCoordinate(value: number, locale = 'en-US'): string {
const formatter = new Intl.NumberFormat(locale, {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
})
return formatter.format(value)
}
export function formatRelativeTime(dateIso: string): string {
export function formatRelativeTime(dateIso: string, locale = 'en-US'): string {
const date = new Date(dateIso)
const now = new Date()
const diff = Math.max(0, now.getTime() - date.getTime())
const seconds = Math.floor(diff / 1000)
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
if (seconds < 60) {
return `${seconds}s ago`
return rtf.format(-seconds, 'second')
}
const minutes = Math.floor(seconds / 60)
if (minutes < 60) {
return `${minutes}m ago`
return rtf.format(-minutes, 'minute')
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h ago`
return rtf.format(-hours, 'hour')
}
const days = Math.floor(hours / 24)
if (days < 7) {
return `${days}d ago`
return rtf.format(-days, 'day')
}
const weeks = Math.floor(days / 7)
return `${weeks}w ago`
return rtf.format(-weeks, 'week')
}
export function formatTimestamp(dateIso: string): string {
export function formatTimestamp(dateIso: string, locale = 'en-US'): string {
const date = new Date(dateIso)
return new Intl.DateTimeFormat('en-US', {
return new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
hour: 'numeric',
+123
View File
@@ -0,0 +1,123 @@
{
"app": {
"name": "SignalMap",
"tagline": "Crowd signals around your route"
},
"status": {
"idle": "Live feed",
"loading": "Syncing map",
"posting": "Sending signal",
"refreshing": "Updating heat",
"error": "Offline"
},
"header": {
"badge": {
"updated": "{{time}}"
},
"actions": {
"refresh": "Refresh now",
"focusHeat": "Focus heatmap",
"locate": "Locate me",
"mySignal": "My last signal"
}
},
"overview": {
"title": "Nearby coverage",
"description": "Signals refresh automatically every few seconds.",
"stats": {
"signals": "Signals",
"contributors": "Contributors"
},
"badge": "Your last signal {{time}}",
"error": {
"action": "Try again"
},
"cta": {
"send": "Drop a signal manually",
"sending": "Sending…",
"waiting": "Waiting for location…"
},
"locationPermission": "Allow location permissions to personalise the feed.",
"lastSynced": "Last synced {{time}}"
},
"hotspots": {
"title": "Danger zone intel",
"description": "Highest intensity heat within {{radius}}km.",
"noLocation": "{{hint}}",
"empty": "No active hotspots nearby. Tap the map to log a new signal.",
"focus": "Focus",
"itemTitle": "Hotspot #{{index}}",
"itemSubtitleWithDistance": "{{distance}} · {{coordinates}}",
"itemSubtitle": "{{coordinates}}"
},
"activity": {
"title": "Live community pings",
"description": "Latest activity reported by nearby contributors.",
"empty": "No recent signals within your area yet.",
"view": "View"
},
"map": {
"posting": "Sending your signal…",
"loading": "Syncing map…",
"confirmationHint": "Confirm the new signal in the dialog to send it."
},
"dialog": {
"confirmSignal": {
"title": "Confirm new signal",
"description": "You're about to publish a community alert at these coordinates. Double-check the spot before confirming.",
"latitude": "Latitude",
"longitude": "Longitude",
"reach": "Signals are visible to travellers within {{radius}}km and help the community stay aware of hotspots.",
"cancel": "Cancel",
"confirm": "Confirm signal",
"sending": "Sending…"
}
},
"location": {
"hint": {
"requesting": "Fetching your location…",
"allow": "Allow location access to view nearby reports.",
"showing": "Showing reports within {{radius}}km of you."
},
"error": {
"permissionDenied": "Location access denied. Enable it to view nearby pings.",
"unavailable": "Unable to determine your position. Try again.",
"timeout": "Timed out while fetching your location.",
"generic": "Failed to retrieve your location.",
"unsupported": "Geolocation is not supported in this browser."
}
},
"activityItem": {
"user": "User {{id}}",
"distance": "{{distance}} away"
},
"common": {
"coordinates": "{{lat}}°, {{lng}}°",
"never": "never",
"actions": {
"close": "Close"
},
"aria": {
"theme": {
"light": "Switch to light mode",
"dark": "Switch to dark mode"
},
"language": "Change language to {{language}}",
"sheet": {
"close": "Close panel"
}
}
},
"language": {
"label": "Language",
"english": "English",
"french": "Français"
},
"errors": {
"feedUnavailable": "Unable to reach the hotspot feed.",
"feedUnknown": "Unknown error while loading hotspots.",
"submitUnavailable": "Unable to store your signal.",
"submitUnknown": "Something went wrong while saving your signal.",
"submitWithReason": "{{message}}"
}
}
+123
View File
@@ -0,0 +1,123 @@
{
"app": {
"name": "SignalMap",
"tagline": "Alertes de la communauté sur votre itinéraire"
},
"status": {
"idle": "Flux en direct",
"loading": "Synchronisation de la carte",
"posting": "Envoi du signal",
"refreshing": "Mise à jour de la chaleur",
"error": "Hors ligne"
},
"header": {
"badge": {
"updated": "{{time}}"
},
"actions": {
"refresh": "Actualiser",
"focusHeat": "Centrer la chaleur",
"locate": "Me localiser",
"mySignal": "Mon dernier signal"
}
},
"overview": {
"title": "Couverture à proximité",
"description": "Les signaux se mettent à jour automatiquement toutes les quelques secondes.",
"stats": {
"signals": "Signaux",
"contributors": "Contributeurs"
},
"badge": "Votre dernier signal {{time}}",
"error": {
"action": "Réessayer"
},
"cta": {
"send": "Déposer un signal manuellement",
"sending": "Envoi…",
"waiting": "En attente de la localisation…"
},
"locationPermission": "Autorisez la localisation pour personnaliser le flux.",
"lastSynced": "Dernière synchronisation {{time}}"
},
"hotspots": {
"title": "Infos sur les zones à risque",
"description": "Chaleur la plus intense dans un rayon de {{radius}} km.",
"noLocation": "{{hint}}",
"empty": "Aucune zone active à proximité. Touchez la carte pour enregistrer un nouveau signal.",
"focus": "Centrer",
"itemTitle": "Zone chaude n°{{index}}",
"itemSubtitleWithDistance": "{{distance}} · {{coordinates}}",
"itemSubtitle": "{{coordinates}}"
},
"activity": {
"title": "Alertes de la communauté",
"description": "Dernières activités signalées à proximité.",
"empty": "Aucun signal récent dans votre secteur.",
"view": "Voir"
},
"map": {
"posting": "Envoi de votre signal…",
"loading": "Synchronisation de la carte…",
"confirmationHint": "Confirmez le nouveau signal dans la fenêtre pour l'envoyer."
},
"dialog": {
"confirmSignal": {
"title": "Confirmer le nouveau signal",
"description": "Vous êtes sur le point de publier une alerte communautaire à ces coordonnées. Vérifiez l'emplacement avant de confirmer.",
"latitude": "Latitude",
"longitude": "Longitude",
"reach": "Les signaux sont visibles par les voyageurs dans un rayon de {{radius}} km et aident la communauté à rester informée des zones à risque.",
"cancel": "Annuler",
"confirm": "Confirmer le signal",
"sending": "Envoi…"
}
},
"location": {
"hint": {
"requesting": "Récupération de votre localisation…",
"allow": "Autorisez l'accès à la localisation pour voir les signalements à proximité.",
"showing": "Affichage des signalements dans un rayon de {{radius}} km autour de vous."
},
"error": {
"permissionDenied": "Accès à la localisation refusé. Activez-le pour voir les alertes proches.",
"unavailable": "Impossible de déterminer votre position. Réessayez.",
"timeout": "Délai dépassé lors de la récupération de votre localisation.",
"generic": "Échec de la récupération de votre localisation.",
"unsupported": "La géolocalisation n'est pas prise en charge par ce navigateur."
}
},
"activityItem": {
"user": "Utilisateur {{id}}",
"distance": "À {{distance}}"
},
"common": {
"coordinates": "{{lat}}°, {{lng}}°",
"never": "jamais",
"actions": {
"close": "Fermer"
},
"aria": {
"theme": {
"light": "Passer en mode clair",
"dark": "Passer en mode sombre"
},
"language": "Changer la langue pour {{language}}",
"sheet": {
"close": "Fermer le panneau"
}
}
},
"language": {
"label": "Langue",
"english": "Anglais",
"french": "Français"
},
"errors": {
"feedUnavailable": "Impossible d'atteindre le flux de signaux.",
"feedUnknown": "Erreur inconnue lors du chargement des signaux.",
"submitUnavailable": "Impossible d'enregistrer votre signal.",
"submitUnknown": "Une erreur est survenue lors de l'enregistrement de votre signal.",
"submitWithReason": "{{message}}"
}
}
+4
View File
@@ -1,12 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { I18nextProvider } from 'react-i18next'
import 'leaflet/dist/leaflet.css'
import '@/index.css'
import App from '@/App.tsx'
import { i18n } from '@/lib/i18n'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<I18nextProvider i18n={i18n}>
<App />
</I18nextProvider>
</StrictMode>,
)
+32
View File
@@ -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
}