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

SignalMap

-

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

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

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

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

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

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

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

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

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

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

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

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

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

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

{emptyMessage}

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

{locationHint}

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

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

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

{nearbySignals}

+
+
+ Contributors +

{uniqueContributors}

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

{errorMessage}

+ +
+
+ ) : ( +

{locationHint}

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

Allow location permissions to personalise the feed.

+ )} +

Last synced {lastUpdatedLabel}

+
+
+ ) +} diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..3310a91 --- /dev/null +++ b/client/src/components/ui/alert-dialog.tsx @@ -0,0 +1,109 @@ +import * as React from 'react' +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' + +import { cn } from '@/lib/utils' + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = 'AlertDialogHeader' + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = 'AlertDialogFooter' + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/client/src/components/ui/scroll-area.tsx b/client/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..cc1174e --- /dev/null +++ b/client/src/components/ui/scroll-area.tsx @@ -0,0 +1,40 @@ +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/client/src/components/ui/sheet.tsx b/client/src/components/ui/sheet.tsx new file mode 100644 index 0000000..a14c745 --- /dev/null +++ b/client/src/components/ui/sheet.tsx @@ -0,0 +1,101 @@ +import * as React from 'react' +import * as SheetPrimitive from '@radix-ui/react-dialog' + +import { cn } from '@/lib/utils' + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +type SheetSide = 'top' | 'bottom' | 'left' | 'right' + +interface SheetContentProps extends React.ComponentPropsWithoutRef { + side?: SheetSide +} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = 'bottom', className, children, ...props }, ref) => ( + + + + {children} + + Close + Close + + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = 'SheetHeader' + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = 'SheetFooter' + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/client/src/components/ui/tabs.tsx b/client/src/components/ui/tabs.tsx new file mode 100644 index 0000000..566dec5 --- /dev/null +++ b/client/src/components/ui/tabs.tsx @@ -0,0 +1,50 @@ +import * as TabsPrimitive from '@radix-ui/react-tabs' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/client/src/hooks/useHotspotFeed.ts b/client/src/hooks/useHotspotFeed.ts new file mode 100644 index 0000000..5d1d32c --- /dev/null +++ b/client/src/hooks/useHotspotFeed.ts @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { distanceInKm } from '@/lib/utils' +import type { ApiDensityCell, ApiPoint, ApiSnapshot, FeedStatus, LatLng } from '@/types/api' + +const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals' +const SNAPSHOT_LIMIT = 750 +const DEFAULT_REFRESH_MS = 7000 +const VISIBLE_RADIUS_KM = 1 + +interface UseHotspotFeedOptions { + autoRefreshMs?: number +} + +interface SubmitResult { + success: boolean +} + +export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) { + const [status, setStatus] = useState('loading') + const [errorMessage, setErrorMessage] = useState(null) + const [rawPoints, setRawPoints] = useState([]) + const [rawDensity, setRawDensity] = useState([]) + const [rawLatestByUser, setRawLatestByUser] = useState([]) + const [clientKey, setClientKey] = useState(null) + const [lastUpdated, setLastUpdated] = useState(null) + + const statusRef = useRef('loading') + const initialLoadRef = useRef(true) + + const setStatusSafe = useCallback((next: FeedStatus) => { + statusRef.current = next + setStatus(next) + }, []) + + const fetchSnapshot = useCallback( + async (options?: { silent?: boolean }) => { + const silent = options?.silent ?? false + const previousStatus = statusRef.current + const isInitial = initialLoadRef.current + + if (previousStatus !== 'posting') { + if (isInitial) { + setStatusSafe('loading') + } else if (!silent) { + setStatusSafe('refreshing') + } + } + + try { + const response = await fetch(`${API_BASE}?limit=${SNAPSHOT_LIMIT}`, { cache: 'no-store' }) + if (!response.ok) { + throw new Error('Unable to reach the hotspot feed.') + } + + const data: ApiSnapshot = await response.json() + setRawPoints(data.points ?? []) + setRawDensity(data.density ?? []) + setRawLatestByUser(data.latestByUser ?? []) + setClientKey(data.clientKey ?? null) + setLastUpdated(data.updatedAt ?? new Date().toISOString()) + setErrorMessage(null) + initialLoadRef.current = false + + const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle' + setStatusSafe(nextStatus) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error while loading hotspots.' + setErrorMessage(message) + if (initialLoadRef.current) { + setStatusSafe('error') + } else if (previousStatus !== 'posting') { + setStatusSafe('idle') + } + } + }, + [setStatusSafe], + ) + + useEffect(() => { + fetchSnapshot().catch(() => undefined) + if (!autoRefreshMs) { + return + } + + const interval = window.setInterval(() => { + fetchSnapshot({ silent: true }).catch(() => undefined) + }, autoRefreshMs) + + return () => { + window.clearInterval(interval) + } + }, [autoRefreshMs, fetchSnapshot]) + + const submitPoint = useCallback( + async (lat: number, lng: number): Promise => { + setStatusSafe('posting') + try { + const response = await fetch(API_BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lat, lng }), + }) + + if (!response.ok) { + const payload = await response.json().catch(() => null) + const message = payload?.message ?? 'Unable to store your signal.' + throw new Error(message) + } + + await fetchSnapshot({ silent: true }) + setStatusSafe('idle') + return { success: true } + } catch (error) { + const message = error instanceof Error ? error.message : 'Something went wrong while saving your signal.' + setErrorMessage(message) + setStatusSafe('error') + return { success: false } + } + }, + [fetchSnapshot, setStatusSafe], + ) + + const hasClientKey = Boolean(clientKey) + + const filterWithinRadius = useCallback( + (collection: T[], origin: LatLng | null) => { + if (!origin) { + return [] + } + return collection.filter((item) => distanceInKm(origin, item) <= VISIBLE_RADIUS_KM) + }, + [], + ) + + const selectVisibleDensity = useCallback( + (origin: LatLng | null) => filterWithinRadius(rawDensity, origin), + [filterWithinRadius, rawDensity], + ) + + const selectVisiblePoints = useCallback( + (origin: LatLng | null) => filterWithinRadius(rawPoints, origin), + [filterWithinRadius, rawPoints], + ) + + const selectVisibleLatestByUser = useCallback( + (origin: LatLng | null) => filterWithinRadius(rawLatestByUser, origin), + [filterWithinRadius, rawLatestByUser], + ) + + const myLatestPoint = useMemo(() => { + if (!hasClientKey) { + return null + } + return rawLatestByUser.find((point) => point.userKey === clientKey) ?? null + }, [clientKey, hasClientKey, rawLatestByUser]) + + return { + status, + errorMessage, + submitPoint, + fetchSnapshot, + rawDensity, + rawPoints, + rawLatestByUser, + clientKey, + lastUpdated, + hasClientKey, + selectVisibleDensity, + selectVisiblePoints, + selectVisibleLatestByUser, + myLatestPoint, + } +} diff --git a/client/src/hooks/useLeafletHeatmap.ts b/client/src/hooks/useLeafletHeatmap.ts new file mode 100644 index 0000000..f8de559 --- /dev/null +++ b/client/src/hooks/useLeafletHeatmap.ts @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useRef } from 'react' +import type { MutableRefObject } from 'react' +import L, { type LeafletMouseEvent, type Map as LeafletMap, type LayerGroup } from 'leaflet' +import 'leaflet.heat' + +import type { ApiDensityCell, LatLng } from '@/types/api' + +type HeatPoint = [number, number, number?] + +type LeafletHeatLayer = L.Layer & { + setLatLngs(points: HeatPoint[]): LeafletHeatLayer + getBounds?: () => L.LatLngBounds +} + +type LeafletWithHeat = typeof L & { + heatLayer?: (points: HeatPoint[], options?: Record) => LeafletHeatLayer +} + +interface UseLeafletHeatmapParams { + heatCells: ApiDensityCell[] + userLocation: LatLng | null + onRequestSpot?: (position: LatLng) => void +} + +interface UseLeafletHeatmapResult { + mapContainerRef: MutableRefObject + focusOn: (position: LatLng, zoom?: number) => void + fitToHeat: () => void + map: LeafletMap | null +} + +const INITIAL_VIEW: LatLng = { lat: 20, lng: 0 } +const DEFAULT_ZOOM = 3 + +export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: UseLeafletHeatmapParams): UseLeafletHeatmapResult { + const mapRef = useRef(null) + const heatLayerRef = useRef(null) + const userLayerRef = useRef(null) + const hasCenteredOnUserRef = useRef(false) + const mapContainerRef = useRef(null) + + const initialiseMap = useCallback(() => { + if (mapRef.current || !mapContainerRef.current) { + return + } + + const leaflet = L as LeafletWithHeat + const container = mapContainerRef.current + + const map = leaflet + .map(container, { + worldCopyJump: true, + minZoom: 2, + zoomControl: false, + attributionControl: false, + }) + .setView([INITIAL_VIEW.lat, INITIAL_VIEW.lng], DEFAULT_ZOOM) + + leaflet + .tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + crossOrigin: true, + maxZoom: 19, + }) + .addTo(map) + + const heatLayer = + typeof leaflet.heatLayer === 'function' + ? leaflet.heatLayer([], { + radius: 32, + blur: 24, + maxZoom: 12, + gradient: { + 0.2: '#38bdf8', + 0.4: '#0ea5e9', + 0.6: '#fbbf24', + 0.8: '#fb923c', + 1: '#ef4444', + }, + }) + : null + + const userLayer = leaflet.layerGroup().addTo(map) + + if (heatLayer) { + heatLayer.addTo(map) + } + + const handleClick = (event: LeafletMouseEvent) => { + const { lat, lng } = event.latlng + if (typeof lat === 'number' && typeof lng === 'number') { + onRequestSpot?.({ lat, lng }) + } + } + + map.on('click', handleClick) + + map.whenReady(() => { + requestAnimationFrame(() => { + map.invalidateSize() + }) + }) + + const onResize = () => { + requestAnimationFrame(() => { + map.invalidateSize() + }) + } + + window.addEventListener('resize', onResize) + + mapRef.current = map + heatLayerRef.current = heatLayer + userLayerRef.current = userLayer + + return () => { + map.off('click', handleClick) + window.removeEventListener('resize', onResize) + map.remove() + mapRef.current = null + heatLayerRef.current = null + userLayerRef.current = null + hasCenteredOnUserRef.current = false + } + }, [onRequestSpot]) + + useEffect(() => { + const cleanup = initialiseMap() + + return () => { + if (typeof cleanup === 'function') { + cleanup() + } + } + }, [initialiseMap]) + + useEffect(() => { + const heatLayer = heatLayerRef.current + if (!heatLayer) { + return + } + + if (!heatCells.length) { + heatLayer.setLatLngs([]) + return + } + + const maxIntensity = Math.max(...heatCells.map((cell) => cell.intensity)) || 1 + const heatPoints: HeatPoint[] = heatCells.map((cell) => [ + cell.lat, + cell.lng, + Math.max(0.2, cell.intensity / maxIntensity), + ]) + + heatLayer.setLatLngs(heatPoints) + }, [heatCells]) + + useEffect(() => { + const userLayer = userLayerRef.current + const map = mapRef.current + if (!userLayer || !map) { + return + } + + userLayer.clearLayers() + + if (!userLocation) { + return + } + + L.circleMarker([userLocation.lat, userLocation.lng], { + radius: 7, + color: '#38bdf8', + weight: 3, + opacity: 0.9, + fillColor: '#38bdf8', + fillOpacity: 0.35, + }).addTo(userLayer) + + if (!hasCenteredOnUserRef.current) { + map.setView([userLocation.lat, userLocation.lng], 13, { animate: true }) + hasCenteredOnUserRef.current = true + } + }, [userLocation]) + + const focusOn = useCallback((position: LatLng, zoom = 14) => { + const map = mapRef.current + if (!map) { + return + } + map.setView([position.lat, position.lng], zoom, { animate: true }) + }, []) + + const fitToHeat = useCallback(() => { + const map = mapRef.current + const heatLayer = heatLayerRef.current + if (!map || !heatLayer) { + return + } + const bounds = heatLayer.getBounds?.() + if (bounds && bounds.isValid()) { + map.fitBounds(bounds.pad(0.25), { animate: true }) + } + }, []) + + return { + mapContainerRef, + focusOn, + fitToHeat, + map: mapRef.current, + } +} diff --git a/client/src/hooks/useTheme.ts b/client/src/hooks/useTheme.ts new file mode 100644 index 0000000..8533712 --- /dev/null +++ b/client/src/hooks/useTheme.ts @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useState } from 'react' + +type Theme = 'light' | 'dark' + +const STORAGE_KEY = 'signalmap-theme' + +function applyTheme(theme: Theme) { + const root = document.documentElement + if (theme === 'dark') { + root.classList.add('dark') + } else { + root.classList.remove('dark') + } +} + +function getPreferredTheme(): Theme { + if (typeof window === 'undefined') { + return 'dark' + } + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored === 'light' || stored === 'dark') { + return stored + } + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + return prefersDark ? 'dark' : 'light' +} + +export function useTheme() { + const [theme, setTheme] = useState(() => getPreferredTheme()) + + useEffect(() => { + applyTheme(theme) + window.localStorage.setItem(STORAGE_KEY, theme) + }, [theme]) + + const toggleTheme = useCallback(() => { + setTheme((current) => (current === 'dark' ? 'light' : 'dark')) + }, []) + + return { + theme, + setTheme, + toggleTheme, + isDark: theme === 'dark', + } +} diff --git a/client/src/hooks/useUserLocation.ts b/client/src/hooks/useUserLocation.ts new file mode 100644 index 0000000..5fdb0ba --- /dev/null +++ b/client/src/hooks/useUserLocation.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { LatLng } from '@/types/api' + +function geolocationErrorMessage(error: GeolocationPositionError): string { + switch (error.code) { + case error.PERMISSION_DENIED: + return 'Location access denied. Enable it to view nearby pings.' + case error.POSITION_UNAVAILABLE: + return 'Unable to determine your position. Try again.' + case error.TIMEOUT: + return 'Timed out while fetching your location.' + default: + return 'Failed to retrieve your location.' + } +} + +export function useUserLocation() { + const [location, setLocation] = useState(null) + const [error, setError] = useState(null) + const [isRequesting, setIsRequesting] = useState(false) + const watchIdRef = useRef(null) + + const clearWatch = useCallback(() => { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + return + } + if (watchIdRef.current !== null) { + navigator.geolocation.clearWatch(watchIdRef.current) + watchIdRef.current = null + } + }, []) + + const start = useCallback(() => { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + setError('Geolocation is not supported in this browser.') + setIsRequesting(false) + return + } + + clearWatch() + setIsRequesting(true) + setError(null) + + navigator.geolocation.getCurrentPosition( + (position) => { + setLocation({ lat: position.coords.latitude, lng: position.coords.longitude }) + setIsRequesting(false) + setError(null) + }, + (geoError) => { + setError(geolocationErrorMessage(geoError)) + setIsRequesting(false) + }, + { enableHighAccuracy: true, timeout: 10000 }, + ) + + const watchId = navigator.geolocation.watchPosition( + (position) => { + setLocation({ lat: position.coords.latitude, lng: position.coords.longitude }) + setError(null) + setIsRequesting(false) + }, + (geoError) => { + setError(geolocationErrorMessage(geoError)) + setIsRequesting(false) + }, + { enableHighAccuracy: true, maximumAge: 15000, timeout: 10000 }, + ) + + watchIdRef.current = watchId + }, [clearWatch]) + + useEffect(() => { + start() + + return () => { + clearWatch() + } + }, [clearWatch, start]) + + return { + location, + error, + isRequesting, + refresh: start, + } +} diff --git a/client/src/index.css b/client/src/index.css index fce2dbc..16deda9 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -5,15 +5,32 @@ @tailwind utilities; @layer base { - html, - body, - #root { - height: 100%; + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 200 98% 39%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 200 98% 39%; + --accent-foreground: 210 40% 98%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 200 98% 39%; + --radius: 0.9rem; } - :root { + .dark { color-scheme: dark; - --background: 222.2 84% 4.9%; + --background: 224 47% 8%; --foreground: 210 40% 98%; --card: 222.2 84% 6.5%; --card-foreground: 210 40% 96%; @@ -23,52 +40,52 @@ --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 96%; - --muted: 223 50% 12%; + --muted: 223 47% 10%; --muted-foreground: 215 20.2% 65.1%; --accent: 199 89% 62%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; - --border: 221.6 30% 23%; - --input: 221.6 30% 23%; + --border: 217 33% 18%; + --input: 217 33% 18%; --ring: 199 89% 62%; - --radius: 0.8rem; - font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } - * { + *, + *::before, + *::after { @apply border-border; } body { - @apply min-h-screen bg-background bg-radial-signal text-foreground antialiased; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + @apply min-h-screen bg-background text-foreground antialiased; + background-image: radial-gradient(circle at top, hsla(var(--primary) / 0.1), transparent 45%), + radial-gradient(circle at bottom, hsla(var(--destructive) / 0.08), transparent 55%); } - p { - @apply leading-relaxed; + #root { + @apply min-h-screen; + } +} + +@layer components { + .leaflet-wrapper, + .leaflet-container { + height: 100%; + width: 100%; } .leaflet-container { font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + z-index: 0; } - .leaflet-tooltip { - background: rgba(15, 23, 42, 0.9); - border: 1px solid rgba(148, 163, 184, 0.35); - color: #e2e8f0; - border-radius: 0.75rem; - box-shadow: 0 15px 35px rgba(2, 6, 23, 0.45); - padding: 0.65rem 0.75rem; - } - - .leaflet-tooltip-top:before, - .leaflet-tooltip-bottom:before, - .leaflet-tooltip-left:before, - .leaflet-tooltip-right:before { - border-top-color: rgba(15, 23, 42, 0.9); - border-bottom-color: rgba(15, 23, 42, 0.9); - border-left-color: rgba(15, 23, 42, 0.9); - border-right-color: rgba(15, 23, 42, 0.9); + .leaflet-pane, + .leaflet-top, + .leaflet-bottom, + .leaflet-control-container { + z-index: 20; } } diff --git a/client/src/types/api.ts b/client/src/types/api.ts new file mode 100644 index 0000000..637334a --- /dev/null +++ b/client/src/types/api.ts @@ -0,0 +1,32 @@ +export type FeedStatus = 'loading' | 'idle' | 'error' | 'posting' | 'refreshing' + +export interface ApiPoint { + id: number + lat: number + lng: number + createdAt: string + userKey: string +} + +export interface ApiDensityCell { + lat: number + lng: number + intensity: number +} + +export interface ApiSnapshot { + clientKey?: string + points?: ApiPoint[] + density?: ApiDensityCell[] + latestByUser?: ApiPoint[] + totals?: { + points: number + contributors: number + } + updatedAt?: string +} + +export type LatLng = { + lat: number + lng: number +} From 0422becdd045e48185af8a7f3db7a1fcc57879c4 Mon Sep 17 00:00:00 2001 From: Bernard Ngandu <31113941+bernard-ng@users.noreply.github.com> Date: Fri, 10 Oct 2025 10:30:28 +0200 Subject: [PATCH 2/2] Add bilingual i18n UI and lighten component shadows --- client/package-lock.json | 88 +++++++++++- client/package.json | 2 + client/src/App.tsx | 133 ++++++++++-------- client/src/components/layout/AppHeader.tsx | 44 +++++- .../src/components/layout/LanguageToggle.tsx | 28 ++++ client/src/components/layout/ThemeToggle.tsx | 5 +- client/src/components/map/MapViewport.tsx | 7 +- .../src/components/panels/ActivityPanel.tsx | 9 +- .../components/panels/HotspotStatsPanel.tsx | 11 +- .../src/components/panels/OverviewPanel.tsx | 31 ++-- client/src/components/ui/alert-dialog.tsx | 2 +- client/src/components/ui/card.tsx | 2 +- client/src/components/ui/sheet.tsx | 53 +++---- client/src/hooks/useHotspotFeed.ts | 37 +++-- client/src/hooks/useUserLocation.ts | 10 +- client/src/lib/i18n.ts | 27 ++++ client/src/lib/utils.ts | 22 +-- client/src/locales/en/common.json | 123 ++++++++++++++++ client/src/locales/fr/common.json | 123 ++++++++++++++++ client/src/main.tsx | 6 +- 20 files changed, 622 insertions(+), 141 deletions(-) create mode 100644 client/src/components/layout/LanguageToggle.tsx create mode 100644 client/src/lib/i18n.ts create mode 100644 client/src/locales/en/common.json create mode 100644 client/src/locales/fr/common.json diff --git a/client/package-lock.json b/client/package-lock.json index dd3ec7a..f1a105c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -15,11 +15,13 @@ "@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", + "react-i18next": "^16.0.0", "tailwind-merge": "^3.3.1", "tslib": "^2.8.1" }, @@ -291,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", @@ -2922,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", @@ -4007,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", @@ -4629,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", @@ -4869,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", diff --git a/client/package.json b/client/package.json index f72bac6..2a2bb8c 100644 --- a/client/package.json +++ b/client/package.json @@ -17,11 +17,13 @@ "@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", + "react-i18next": "^16.0.0", "tailwind-merge": "^3.3.1", "tslib": "^2.8.1" }, diff --git a/client/src/App.tsx b/client/src/App.tsx index 1e74fe0..d47e072 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { AppHeader } from '@/components/layout/AppHeader' import { ActivityPanel } from '@/components/panels/ActivityPanel' @@ -19,33 +20,28 @@ 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' +import type { LatLng } from '@/types/api' const RADIUS_KM = 1 -function getStatusLabel(status: FeedStatus): string { - switch (status) { - case 'loading': - return 'Syncing map' - case 'posting': - return 'Sending signal' - case 'refreshing': - return 'Updating heat' - case 'error': - return 'Offline' - default: - return 'Live feed' - } -} - export default function App() { const [pendingSpot, setPendingSpot] = useState(null) const [isConfirmOpen, setIsConfirmOpen] = useState(false) const [isConfirming, setIsConfirming] = useState(false) + const { t, i18n } = useTranslation() + const locale = i18n.language === 'fr' ? 'fr-FR' : 'en-US' + const distanceFormatter = useMemo( + () => + new Intl.NumberFormat(locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [locale], + ) const { status, - errorMessage, + error, submitPoint, fetchSnapshot, selectVisibleDensity, @@ -88,21 +84,22 @@ export default function App() { return myLatestPoint }, [myLatestPoint, userLocation]) - const statusLabel = getStatusLabel(status) - const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated) : 'never' + const statusLabel = t(`status.${status}`) + const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated, locale) : t('common.never') const isLoading = status === 'loading' const isPosting = status === 'posting' const isRefreshing = status === 'refreshing' const hasLocation = Boolean(userLocation) const showLocationCta = !hasLocation || Boolean(locationError) - const locationHint = locationError - ? locationError + const translatedLocationError = locationError ? t(locationError) : null + const locationHint = translatedLocationError + ? translatedLocationError : hasLocation - ? `Showing reports within ${RADIUS_KM}km of you.` + ? t('location.hint.showing', { radius: RADIUS_KM }) : isRequestingLocation - ? 'Fetching your location…' - : 'Allow location access to view nearby reports.' + ? t('location.hint.requesting') + : t('location.hint.allow') const { mapContainerRef, focusOn, fitToHeat } = useLeafletHeatmap({ heatCells: visibleDensity, @@ -159,16 +156,25 @@ export default function App() { [...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], + .map((cell, index) => { + const coordinates = t('common.coordinates', { + lat: formatCoordinate(cell.lat, locale), + lng: formatCoordinate(cell.lng, locale), + }) + return { + id: `${cell.lat}-${cell.lng}-${index}`, + title: t('hotspots.itemTitle', { index: index + 1 }), + subtitle: hasLocation + ? t('hotspots.itemSubtitleWithDistance', { + distance: `${distanceFormatter.format(distanceInKm(userLocation!, cell))} km`, + coordinates, + }) + : t('hotspots.itemSubtitle', { coordinates }), + intensity: cell.intensity, + onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15), + } + }), + [visibleDensity, hasLocation, userLocation, focusOn, distanceFormatter, t, locale], ) const recentActivity = useMemo( @@ -176,21 +182,30 @@ export default function App() { [...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], + .map((point) => { + const coordinates = t('common.coordinates', { + lat: formatCoordinate(point.lat, locale), + lng: formatCoordinate(point.lng, locale), + }) + const distanceLabel = userLocation + ? t('activityItem.distance', { + distance: `${distanceFormatter.format(distanceInKm(userLocation, point))} km`, + }) + : formatTimestamp(point.createdAt, locale) + return { + id: point.id, + title: coordinates, + subtitle: t('activityItem.user', { id: point.userKey.slice(0, 4).toUpperCase() }), + timestampLabel: formatRelativeTime(point.createdAt, locale), + distanceLabel, + onFocus: () => focusOn({ lat: point.lat, lng: point.lng }, 15), + } + }), + [visiblePoints, userLocation, focusOn, distanceFormatter, t, locale], ) - const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat) : '--' - const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng) : '--' + const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat, locale) : '--' + const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng, locale) : '--' const isDialogDisabled = !pendingSpot || isConfirming return ( @@ -216,8 +231,8 @@ export default function App() { nearbySignals={localTotals.points} uniqueContributors={localTotals.contributors} lastUpdatedLabel={lastUpdatedLabel} - mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt) : null} - errorMessage={errorMessage} + mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt, locale) : null} + error={error} onReport={handleManualReport} onRetry={handleRefresh} isPosting={isPosting || isConfirming} @@ -231,7 +246,7 @@ export default function App() { locationHint={locationHint} cells={dangerCells} /> - +
@@ -239,7 +254,7 @@ export default function App() { containerRef={mapContainerRef} isPosting={isPosting || isConfirming} isLoading={isLoading} - confirmationHint={isConfirmOpen ? 'Confirm the new signal in the dialog to send it.' : null} + confirmationHint={isConfirmOpen ? t('map.confirmationHint') : null} />
@@ -257,26 +272,24 @@ export default function App() { > - Confirm new signal - - You're about to publish a community alert at these coordinates. Double-check the spot before confirming. - + {t('dialog.confirmSignal.title')} + {t('dialog.confirmSignal.description')}
- Latitude + {t('dialog.confirmSignal.latitude')} {confirmationLat}°
- Longitude + {t('dialog.confirmSignal.longitude')} {confirmationLng}°

- Signals are visible to travellers within {RADIUS_KM}km and help the community stay aware of hotspots. + {t('dialog.confirmSignal.reach', { radius: RADIUS_KM })}

- Cancel + {t('dialog.confirmSignal.cancel')} { @@ -284,7 +297,7 @@ export default function App() { handleConfirmSignal().catch(() => undefined) }} > - {isConfirming ? 'Sending…' : 'Confirm signal'} + {isConfirming ? t('dialog.confirmSignal.sending') : t('dialog.confirmSignal.confirm')}
diff --git a/client/src/components/layout/AppHeader.tsx b/client/src/components/layout/AppHeader.tsx index b72277d..7bee720 100644 --- a/client/src/components/layout/AppHeader.tsx +++ b/client/src/components/layout/AppHeader.tsx @@ -1,7 +1,9 @@ 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' @@ -32,6 +34,7 @@ export function AppHeader({ disableLocate, disableMySignal, }: AppHeaderProps) { + const { t } = useTranslation() const isError = status === 'error' return ( @@ -42,8 +45,8 @@ export function AppHeader({
- SignalMap - Crowd signals around your route + {t('app.name')} + {t('app.tagline')}
@@ -62,20 +65,47 @@ export function AppHeader({ {statusLabel} - {lastUpdatedLabel} + + {t('header.badge.updated', { time: lastUpdatedLabel })} + - - - - +
diff --git a/client/src/components/layout/LanguageToggle.tsx b/client/src/components/layout/LanguageToggle.tsx new file mode 100644 index 0000000..63480c4 --- /dev/null +++ b/client/src/components/layout/LanguageToggle.tsx @@ -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 ( + + ) +} + diff --git a/client/src/components/layout/ThemeToggle.tsx b/client/src/components/layout/ThemeToggle.tsx index ed19503..373ae3e 100644 --- a/client/src/components/layout/ThemeToggle.tsx +++ b/client/src/components/layout/ThemeToggle.tsx @@ -1,16 +1,19 @@ 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 (
diff --git a/client/src/components/panels/HotspotStatsPanel.tsx b/client/src/components/panels/HotspotStatsPanel.tsx index 7c5a669..e3ff0d9 100644 --- a/client/src/components/panels/HotspotStatsPanel.tsx +++ b/client/src/components/panels/HotspotStatsPanel.tsx @@ -1,4 +1,5 @@ import { Flame, MapPin } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -21,19 +22,21 @@ interface HotspotStatsPanelProps { } export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells }: HotspotStatsPanelProps) { + const { t } = useTranslation() + return ( - Danger zone intel + {t('hotspots.title')} - Highest intensity heat within {radiusKm}km. + {t('hotspots.description', { radius: radiusKm })} {!hasLocation &&

{locationHint}

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

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

+

{t('hotspots.empty')}

)} {cells.length > 0 && ( @@ -55,7 +58,7 @@ export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells } className="mt-2 w-full justify-center gap-2 text-xs" onClick={cell.onFocus} > - Focus + {t('hotspots.focus')} ))} diff --git a/client/src/components/panels/OverviewPanel.tsx b/client/src/components/panels/OverviewPanel.tsx index 514e2e9..c343198 100644 --- a/client/src/components/panels/OverviewPanel.tsx +++ b/client/src/components/panels/OverviewPanel.tsx @@ -1,15 +1,17 @@ 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 - errorMessage: string | null + error: FeedError | null onReport: () => void onRetry: () => void isPosting: boolean @@ -23,7 +25,7 @@ export function OverviewPanel({ uniqueContributors, lastUpdatedLabel, mySignalLabel, - errorMessage, + error, onReport, onRetry, isPosting, @@ -31,29 +33,32 @@ export function OverviewPanel({ showLocationCta, disableReport, }: OverviewPanelProps) { + const { t } = useTranslation() + const errorMessage = error ? t(error.key, error.values) : null + return ( - Nearby coverage + {t('overview.title')} - Signals refresh automatically every few seconds. + {t('overview.description')}
- Signals + {t('overview.stats.signals')}

{nearbySignals}

- Contributors + {t('overview.stats.contributors')}

{uniqueContributors}

{mySignalLabel && ( - Your last signal {mySignalLabel} + {t('overview.badge', { time: mySignalLabel })} )} {errorMessage ? ( @@ -62,7 +67,7 @@ export function OverviewPanel({

{errorMessage}

@@ -70,12 +75,16 @@ export function OverviewPanel({

{locationHint}

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

Allow location permissions to personalise the feed.

+

{t('overview.locationPermission')}

)} -

Last synced {lastUpdatedLabel}

+

{t('overview.lastSynced', { time: lastUpdatedLabel })}

) diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx index 3310a91..26a38ba 100644 --- a/client/src/components/ui/alert-dialog.tsx +++ b/client/src/components/ui/alert-dialog.tsx @@ -33,7 +33,7 @@ const AlertDialogContent = React.forwardRef< , SheetContentProps ->(({ side = 'bottom', className, children, ...props }, ref) => ( - - - - {children} - - Close - Close - - - -)) +>(({ side = 'bottom', className, children, ...props }, ref) => { + const { t } = useTranslation() + + return ( + + + + {children} + + {t('common.actions.close')} + {t('common.aria.sheet.close')} + + + + ) +}) SheetContent.displayName = SheetPrimitive.Content.displayName const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( diff --git a/client/src/hooks/useHotspotFeed.ts b/client/src/hooks/useHotspotFeed.ts index 5d1d32c..c817dda 100644 --- a/client/src/hooks/useHotspotFeed.ts +++ b/client/src/hooks/useHotspotFeed.ts @@ -16,9 +16,14 @@ interface SubmitResult { success: boolean } +export interface FeedError { + key: string + values?: Record +} + export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) { const [status, setStatus] = useState('loading') - const [errorMessage, setErrorMessage] = useState(null) + const [error, setError] = useState(null) const [rawPoints, setRawPoints] = useState([]) const [rawDensity, setRawDensity] = useState([]) const [rawLatestByUser, setRawLatestByUser] = useState([]) @@ -50,7 +55,7 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo 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.') + throw new Error('feed-unavailable') } const data: ApiSnapshot = await response.json() @@ -59,14 +64,15 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo setRawLatestByUser(data.latestByUser ?? []) setClientKey(data.clientKey ?? null) setLastUpdated(data.updatedAt ?? new Date().toISOString()) - setErrorMessage(null) + setError(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) + 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') { @@ -104,16 +110,27 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo if (!response.ok) { const payload = await response.json().catch(() => null) - const message = payload?.message ?? 'Unable to store your signal.' - throw new Error(message) + 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 : 'Something went wrong while saving your signal.' - setErrorMessage(message) + 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 } } @@ -157,7 +174,7 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo return { status, - errorMessage, + error, submitPoint, fetchSnapshot, rawDensity, diff --git a/client/src/hooks/useUserLocation.ts b/client/src/hooks/useUserLocation.ts index 5fdb0ba..376d00e 100644 --- a/client/src/hooks/useUserLocation.ts +++ b/client/src/hooks/useUserLocation.ts @@ -5,13 +5,13 @@ 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.' + return 'location.error.permissionDenied' case error.POSITION_UNAVAILABLE: - return 'Unable to determine your position. Try again.' + return 'location.error.unavailable' case error.TIMEOUT: - return 'Timed out while fetching your location.' + return 'location.error.timeout' default: - return 'Failed to retrieve your location.' + return 'location.error.generic' } } @@ -33,7 +33,7 @@ export function useUserLocation() { const start = useCallback(() => { if (typeof navigator === 'undefined' || !navigator.geolocation) { - setError('Geolocation is not supported in this browser.') + setError('location.error.unsupported') setIsRequesting(false) return } diff --git a/client/src/lib/i18n.ts b/client/src/lib/i18n.ts new file mode 100644 index 0000000..d5b6ce3 --- /dev/null +++ b/client/src/lib/i18n.ts @@ -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 } + diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts index 8106ff1..d0703cb 100644 --- a/client/src/lib/utils.ts +++ b/client/src/lib/utils.ts @@ -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', diff --git a/client/src/locales/en/common.json b/client/src/locales/en/common.json new file mode 100644 index 0000000..535f815 --- /dev/null +++ b/client/src/locales/en/common.json @@ -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}}" + } +} diff --git a/client/src/locales/fr/common.json b/client/src/locales/fr/common.json new file mode 100644 index 0000000..9435c7a --- /dev/null +++ b/client/src/locales/fr/common.json @@ -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}}" + } +} diff --git a/client/src/main.tsx b/client/src/main.tsx index f9078a5..48a499b 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -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( - + + + , )