Merge pull request #3 from bernard-ng/codex/fix-ui-and-organize-client-code
Refactor client UI with shadcn components
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
Generated
+709
-6
@@ -8,14 +8,22 @@
|
||||
"name": "client",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^25.5.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"react-i18next": "^16.0.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
@@ -285,6 +293,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
@@ -726,6 +743,72 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
@@ -741,6 +824,290 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
|
||||
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area": {
|
||||
"version": "1.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
|
||||
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
@@ -759,6 +1126,121 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-beta.41",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.41.tgz",
|
||||
@@ -1115,7 +1597,7 @@
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz",
|
||||
"integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -1527,6 +2009,18 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.21",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||
@@ -1894,6 +2388,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -2321,6 +2821,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
@@ -2424,6 +2933,46 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.5.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.5.3.tgz",
|
||||
"integrity": "sha512-joFqorDeQ6YpIXni944upwnuHBf5IoPMuqAchGVeQLdWC2JOjxgM9V8UGLhNIIH/Q8QleRxIi0BSRQehSrDLcg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com/i18next.html"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -2982,6 +3531,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.545.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz",
|
||||
"integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -3500,6 +4058,32 @@
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.0.0.tgz",
|
||||
"integrity": "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 25.5.2",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -3510,6 +4094,75 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
||||
"integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"tslib": "^2.1.0",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-style-singleton": "^2.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-nonce": "^1.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -4034,9 +4687,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
@@ -4055,7 +4706,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -4137,6 +4788,49 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-node-es": "^1.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -4252,6 +4946,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
+9
-1
@@ -10,14 +10,22 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^25.5.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"react-i18next": "^16.0.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
|
||||
+256
-705
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
import { Flame, Focus, LocateFixed, MapPin, RefreshCw } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LanguageToggle } from '@/components/layout/LanguageToggle'
|
||||
import { ThemeToggle } from '@/components/layout/ThemeToggle'
|
||||
import type { FeedStatus } from '@/types/api'
|
||||
|
||||
interface AppHeaderProps {
|
||||
status: FeedStatus
|
||||
statusLabel: string
|
||||
lastUpdatedLabel: string
|
||||
onRefresh: () => void
|
||||
onFocusHeat: () => void
|
||||
onLocateUser: () => void
|
||||
onFocusMySignal: () => void
|
||||
disableRefresh: boolean
|
||||
disableHeat: boolean
|
||||
disableLocate: boolean
|
||||
disableMySignal: boolean
|
||||
}
|
||||
|
||||
export function AppHeader({
|
||||
status,
|
||||
statusLabel,
|
||||
lastUpdatedLabel,
|
||||
onRefresh,
|
||||
onFocusHeat,
|
||||
onLocateUser,
|
||||
onFocusMySignal,
|
||||
disableRefresh,
|
||||
disableHeat,
|
||||
disableLocate,
|
||||
disableMySignal,
|
||||
}: AppHeaderProps) {
|
||||
const { t } = useTranslation()
|
||||
const isError = status === 'error'
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-border/60 bg-background/80 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-border/60 bg-primary/10 text-primary">
|
||||
<Flame className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-semibold sm:text-xl">{t('app.name')}</span>
|
||||
<span className="text-xs text-muted-foreground sm:text-sm">{t('app.tagline')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
'inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/60 px-3 py-1 text-xs font-medium uppercase tracking-wide'
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-2 ${isError ? 'text-destructive' : 'text-primary'}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="relative block h-2.5 w-2.5 rounded-full bg-current">
|
||||
<span className="absolute inset-[-0.35rem] rounded-full border border-current opacity-40 animate-status-pulse" />
|
||||
</span>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase text-muted-foreground">
|
||||
{t('header.badge.updated', { time: lastUpdatedLabel })}
|
||||
</span>
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={disableRefresh}
|
||||
aria-label={t('header.actions.refresh')}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={onFocusHeat}
|
||||
disabled={disableHeat}
|
||||
aria-label={t('header.actions.focusHeat')}
|
||||
>
|
||||
<Focus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onLocateUser}
|
||||
disabled={disableLocate}
|
||||
aria-label={t('header.actions.locate')}
|
||||
>
|
||||
<LocateFixed className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onFocusMySignal}
|
||||
disabled={disableMySignal}
|
||||
aria-label={t('header.actions.mySignal')}
|
||||
>
|
||||
<MapPin className="h-4 w-4" />
|
||||
</Button>
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Globe } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function LanguageToggle() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const current = i18n.language === 'fr' ? 'fr' : 'en'
|
||||
const next = current === 'en' ? 'fr' : 'en'
|
||||
const nextLabel = t(next === 'en' ? 'language.english' : 'language.french')
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
i18n.changeLanguage(next).catch(() => undefined)
|
||||
}}
|
||||
aria-label={t('common.aria.language', { language: nextLabel })}
|
||||
className="h-9 rounded-full border border-border/60 bg-background/80 px-3 backdrop-blur"
|
||||
>
|
||||
<span className="sr-only">{t('language.label')}</span>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="ml-2 text-xs font-semibold uppercase text-muted-foreground">{current.toUpperCase()}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTheme } from '@/hooks/useTheme'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { toggleTheme, isDark } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
aria-label={isDark ? t('common.aria.theme.light') : t('common.aria.theme.dark')}
|
||||
className="rounded-full border border-border/60 bg-background/80 backdrop-blur"
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MapViewportProps {
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>
|
||||
isPosting: boolean
|
||||
isLoading: boolean
|
||||
confirmationHint?: string | null
|
||||
}
|
||||
|
||||
export function MapViewport({ containerRef, isPosting, isLoading, confirmationHint }: MapViewportProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[360px] flex-1 overflow-hidden rounded-3xl border border-border/50 bg-muted/40 shadow-inner">
|
||||
<div ref={containerRef} className={cn('absolute inset-0 z-0', 'leaflet-wrapper')} />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background/70 to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-24 bg-gradient-to-t from-background/70 to-transparent" />
|
||||
{(isPosting || isLoading) && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||
<span className="rounded-full border border-border/60 bg-background/80 px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground backdrop-blur">
|
||||
{isPosting ? t('map.posting') : t('map.loading')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{confirmationHint && (
|
||||
<div className="pointer-events-none absolute bottom-6 left-1/2 z-20 w-[90%] max-w-sm -translate-x-1/2 rounded-full border border-border/70 bg-background/90 px-4 py-2 text-xs text-muted-foreground shadow-sm">
|
||||
{confirmationHint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Activity, ArrowRight } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
interface ActivityItem {
|
||||
id: string | number
|
||||
title: string
|
||||
subtitle: string
|
||||
timestampLabel: string
|
||||
distanceLabel: string
|
||||
onFocus: () => void
|
||||
}
|
||||
|
||||
interface ActivityPanelProps {
|
||||
items: ActivityItem[]
|
||||
emptyMessage: string
|
||||
}
|
||||
|
||||
export function ActivityPanel({ items, emptyMessage }: ActivityPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
{t('activity.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('activity.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{items.length === 0 && <p className="text-sm text-muted-foreground">{emptyMessage}</p>}
|
||||
{items.length > 0 && (
|
||||
<ScrollArea className="max-h-[280px] pr-2">
|
||||
<ul className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="rounded-2xl border border-border/60 bg-muted/50 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-foreground">{item.title}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.subtitle}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-border/60 text-[10px] uppercase text-muted-foreground">
|
||||
{item.timestampLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{item.distanceLabel}</span>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-2 text-xs" onClick={item.onFocus}>
|
||||
{t('activity.view')}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Flame, MapPin } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
interface HotspotCellInfo {
|
||||
id: string
|
||||
title: string
|
||||
subtitle: string
|
||||
intensity: number
|
||||
onFocus: () => void
|
||||
}
|
||||
|
||||
interface HotspotStatsPanelProps {
|
||||
hasLocation: boolean
|
||||
radiusKm: number
|
||||
locationHint: string
|
||||
cells: HotspotCellInfo[]
|
||||
}
|
||||
|
||||
export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells }: HotspotStatsPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Flame className="h-5 w-5 text-primary" />
|
||||
{t('hotspots.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('hotspots.description', { radius: radiusKm })}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!hasLocation && <p className="text-sm text-muted-foreground">{locationHint}</p>}
|
||||
{hasLocation && cells.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('hotspots.empty')}</p>
|
||||
)}
|
||||
{cells.length > 0 && (
|
||||
<ScrollArea className="max-h-[280px] pr-2">
|
||||
<ul className="space-y-3">
|
||||
{cells.map((cell) => (
|
||||
<li key={cell.id} className="rounded-2xl border border-border/60 bg-muted/50 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-foreground">{cell.title}</span>
|
||||
<span className="text-xs text-muted-foreground">{cell.subtitle}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-primary/40 text-xs text-primary">
|
||||
{cell.intensity.toFixed(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 w-full justify-center gap-2 text-xs"
|
||||
onClick={cell.onFocus}
|
||||
>
|
||||
<MapPin className="h-3.5 w-3.5" /> {t('hotspots.focus')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { AlertCircle, Radio } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import type { FeedError } from '@/hooks/useHotspotFeed'
|
||||
|
||||
interface OverviewPanelProps {
|
||||
nearbySignals: number
|
||||
uniqueContributors: number
|
||||
lastUpdatedLabel: string
|
||||
mySignalLabel: string | null
|
||||
error: FeedError | null
|
||||
onReport: () => void
|
||||
onRetry: () => void
|
||||
isPosting: boolean
|
||||
locationHint: string
|
||||
showLocationCta: boolean
|
||||
disableReport: boolean
|
||||
}
|
||||
|
||||
export function OverviewPanel({
|
||||
nearbySignals,
|
||||
uniqueContributors,
|
||||
lastUpdatedLabel,
|
||||
mySignalLabel,
|
||||
error,
|
||||
onReport,
|
||||
onRetry,
|
||||
isPosting,
|
||||
locationHint,
|
||||
showLocationCta,
|
||||
disableReport,
|
||||
}: OverviewPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const errorMessage = error ? t(error.key, error.values) : null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radio className="h-5 w-5 text-primary" />
|
||||
{t('overview.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('overview.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/50 p-3">
|
||||
<span className="text-xs uppercase text-muted-foreground">{t('overview.stats.signals')}</span>
|
||||
<p className="text-xl font-semibold">{nearbySignals}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/50 p-3">
|
||||
<span className="text-xs uppercase text-muted-foreground">{t('overview.stats.contributors')}</span>
|
||||
<p className="text-xl font-semibold">{uniqueContributors}</p>
|
||||
</div>
|
||||
</div>
|
||||
{mySignalLabel && (
|
||||
<Badge variant="secondary" className="w-full justify-center rounded-full py-2 text-xs uppercase">
|
||||
{t('overview.badge', { time: mySignalLabel })}
|
||||
</Badge>
|
||||
)}
|
||||
{errorMessage ? (
|
||||
<div className="flex items-start gap-3 rounded-2xl border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4" />
|
||||
<div className="space-y-2">
|
||||
<p>{errorMessage}</p>
|
||||
<Button variant="outline" size="sm" className="text-xs" onClick={onRetry}>
|
||||
{t('overview.error.action')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{locationHint}</p>
|
||||
)}
|
||||
<Button className="w-full" onClick={onReport} disabled={isPosting || disableReport}>
|
||||
{isPosting
|
||||
? t('overview.cta.sending')
|
||||
: disableReport
|
||||
? t('overview.cta.waiting')
|
||||
: t('overview.cta.send')}
|
||||
</Button>
|
||||
{showLocationCta && !errorMessage && (
|
||||
<p className="text-xs text-muted-foreground">{t('overview.locationPermission')}</p>
|
||||
)}
|
||||
<p className="text-[11px] uppercase text-muted-foreground">{t('overview.lastSynced', { time: lastUpdatedLabel })}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border border-border/70 bg-background p-6 shadow-sm duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
|
||||
)
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn('inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn('inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50 sm:mr-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border border-border/60 bg-card/80 text-card-foreground shadow-xl shadow-black/30 backdrop-blur',
|
||||
'rounded-xl border border-border/60 bg-card/80 text-card-foreground shadow-sm backdrop-blur',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2 border-t border-t-transparent p-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border/60" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,106 @@
|
||||
import * as React from 'react'
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-40 bg-background/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
type SheetSide = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> {
|
||||
side?: SheetSide
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = 'bottom', className, children, ...props }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed z-50 grid gap-4 bg-background p-6 shadow-sm transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
side === 'bottom' && 'inset-x-0 bottom-0 rounded-t-2xl border-t',
|
||||
side === 'top' && 'inset-x-0 top-0 rounded-b-2xl border-b',
|
||||
side === 'left' && 'inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'right' && 'inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
className,
|
||||
)}
|
||||
data-side={side}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-6 top-6 rounded-full border border-border/50 bg-background/60 px-3 py-1 text-xs font-medium text-muted-foreground transition hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background">
|
||||
{t('common.actions.close')}
|
||||
<span className="sr-only">{t('common.aria.sheet.close')}</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
})
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
SheetHeader.displayName = 'SheetHeader'
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
|
||||
)
|
||||
SheetFooter.displayName = 'SheetFooter'
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('inline-flex h-10 items-center justify-center rounded-full bg-muted p-1 text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex min-w-[120px] items-center justify-center whitespace-nowrap rounded-full px-4 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=inactive]:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { distanceInKm } from '@/lib/utils'
|
||||
import type { ApiDensityCell, ApiPoint, ApiSnapshot, FeedStatus, LatLng } from '@/types/api'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals'
|
||||
const SNAPSHOT_LIMIT = 750
|
||||
const DEFAULT_REFRESH_MS = 7000
|
||||
const VISIBLE_RADIUS_KM = 1
|
||||
|
||||
interface UseHotspotFeedOptions {
|
||||
autoRefreshMs?: number
|
||||
}
|
||||
|
||||
interface SubmitResult {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface FeedError {
|
||||
key: string
|
||||
values?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) {
|
||||
const [status, setStatus] = useState<FeedStatus>('loading')
|
||||
const [error, setError] = useState<FeedError | null>(null)
|
||||
const [rawPoints, setRawPoints] = useState<ApiPoint[]>([])
|
||||
const [rawDensity, setRawDensity] = useState<ApiDensityCell[]>([])
|
||||
const [rawLatestByUser, setRawLatestByUser] = useState<ApiPoint[]>([])
|
||||
const [clientKey, setClientKey] = useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null)
|
||||
|
||||
const statusRef = useRef<FeedStatus>('loading')
|
||||
const initialLoadRef = useRef(true)
|
||||
|
||||
const setStatusSafe = useCallback((next: FeedStatus) => {
|
||||
statusRef.current = next
|
||||
setStatus(next)
|
||||
}, [])
|
||||
|
||||
const fetchSnapshot = useCallback(
|
||||
async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false
|
||||
const previousStatus = statusRef.current
|
||||
const isInitial = initialLoadRef.current
|
||||
|
||||
if (previousStatus !== 'posting') {
|
||||
if (isInitial) {
|
||||
setStatusSafe('loading')
|
||||
} else if (!silent) {
|
||||
setStatusSafe('refreshing')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}?limit=${SNAPSHOT_LIMIT}`, { cache: 'no-store' })
|
||||
if (!response.ok) {
|
||||
throw new Error('feed-unavailable')
|
||||
}
|
||||
|
||||
const data: ApiSnapshot = await response.json()
|
||||
setRawPoints(data.points ?? [])
|
||||
setRawDensity(data.density ?? [])
|
||||
setRawLatestByUser(data.latestByUser ?? [])
|
||||
setClientKey(data.clientKey ?? null)
|
||||
setLastUpdated(data.updatedAt ?? new Date().toISOString())
|
||||
setError(null)
|
||||
initialLoadRef.current = false
|
||||
|
||||
const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle'
|
||||
setStatusSafe(nextStatus)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : null
|
||||
const key = message === 'feed-unavailable' ? 'errors.feedUnavailable' : 'errors.feedUnknown'
|
||||
setError({ key })
|
||||
if (initialLoadRef.current) {
|
||||
setStatusSafe('error')
|
||||
} else if (previousStatus !== 'posting') {
|
||||
setStatusSafe('idle')
|
||||
}
|
||||
}
|
||||
},
|
||||
[setStatusSafe],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSnapshot().catch(() => undefined)
|
||||
if (!autoRefreshMs) {
|
||||
return
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
fetchSnapshot({ silent: true }).catch(() => undefined)
|
||||
}, autoRefreshMs)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval)
|
||||
}
|
||||
}, [autoRefreshMs, fetchSnapshot])
|
||||
|
||||
const submitPoint = useCallback(
|
||||
async (lat: number, lng: number): Promise<SubmitResult> => {
|
||||
setStatusSafe('posting')
|
||||
try {
|
||||
const response = await fetch(API_BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lat, lng }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null)
|
||||
const message = payload?.message as string | undefined
|
||||
if (message) {
|
||||
setError({ key: 'errors.submitWithReason', values: { message } })
|
||||
} else {
|
||||
setError({ key: 'errors.submitUnavailable' })
|
||||
}
|
||||
setStatusSafe('error')
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
await fetchSnapshot({ silent: true })
|
||||
setError(null)
|
||||
setStatusSafe('idle')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : null
|
||||
if (message) {
|
||||
setError({ key: 'errors.submitWithReason', values: { message } })
|
||||
} else {
|
||||
setError({ key: 'errors.submitUnknown' })
|
||||
}
|
||||
setStatusSafe('error')
|
||||
return { success: false }
|
||||
}
|
||||
},
|
||||
[fetchSnapshot, setStatusSafe],
|
||||
)
|
||||
|
||||
const hasClientKey = Boolean(clientKey)
|
||||
|
||||
const filterWithinRadius = useCallback(
|
||||
<T extends LatLng>(collection: T[], origin: LatLng | null) => {
|
||||
if (!origin) {
|
||||
return []
|
||||
}
|
||||
return collection.filter((item) => distanceInKm(origin, item) <= VISIBLE_RADIUS_KM)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const selectVisibleDensity = useCallback(
|
||||
(origin: LatLng | null) => filterWithinRadius(rawDensity, origin),
|
||||
[filterWithinRadius, rawDensity],
|
||||
)
|
||||
|
||||
const selectVisiblePoints = useCallback(
|
||||
(origin: LatLng | null) => filterWithinRadius(rawPoints, origin),
|
||||
[filterWithinRadius, rawPoints],
|
||||
)
|
||||
|
||||
const selectVisibleLatestByUser = useCallback(
|
||||
(origin: LatLng | null) => filterWithinRadius(rawLatestByUser, origin),
|
||||
[filterWithinRadius, rawLatestByUser],
|
||||
)
|
||||
|
||||
const myLatestPoint = useMemo(() => {
|
||||
if (!hasClientKey) {
|
||||
return null
|
||||
}
|
||||
return rawLatestByUser.find((point) => point.userKey === clientKey) ?? null
|
||||
}, [clientKey, hasClientKey, rawLatestByUser])
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
submitPoint,
|
||||
fetchSnapshot,
|
||||
rawDensity,
|
||||
rawPoints,
|
||||
rawLatestByUser,
|
||||
clientKey,
|
||||
lastUpdated,
|
||||
hasClientKey,
|
||||
selectVisibleDensity,
|
||||
selectVisiblePoints,
|
||||
selectVisibleLatestByUser,
|
||||
myLatestPoint,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import L, { type LeafletMouseEvent, type Map as LeafletMap, type LayerGroup } from 'leaflet'
|
||||
import 'leaflet.heat'
|
||||
|
||||
import type { ApiDensityCell, LatLng } from '@/types/api'
|
||||
|
||||
type HeatPoint = [number, number, number?]
|
||||
|
||||
type LeafletHeatLayer = L.Layer & {
|
||||
setLatLngs(points: HeatPoint[]): LeafletHeatLayer
|
||||
getBounds?: () => L.LatLngBounds
|
||||
}
|
||||
|
||||
type LeafletWithHeat = typeof L & {
|
||||
heatLayer?: (points: HeatPoint[], options?: Record<string, unknown>) => LeafletHeatLayer
|
||||
}
|
||||
|
||||
interface UseLeafletHeatmapParams {
|
||||
heatCells: ApiDensityCell[]
|
||||
userLocation: LatLng | null
|
||||
onRequestSpot?: (position: LatLng) => void
|
||||
}
|
||||
|
||||
interface UseLeafletHeatmapResult {
|
||||
mapContainerRef: MutableRefObject<HTMLDivElement | null>
|
||||
focusOn: (position: LatLng, zoom?: number) => void
|
||||
fitToHeat: () => void
|
||||
map: LeafletMap | null
|
||||
}
|
||||
|
||||
const INITIAL_VIEW: LatLng = { lat: 20, lng: 0 }
|
||||
const DEFAULT_ZOOM = 3
|
||||
|
||||
export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: UseLeafletHeatmapParams): UseLeafletHeatmapResult {
|
||||
const mapRef = useRef<LeafletMap | null>(null)
|
||||
const heatLayerRef = useRef<LeafletHeatLayer | null>(null)
|
||||
const userLayerRef = useRef<LayerGroup | null>(null)
|
||||
const hasCenteredOnUserRef = useRef(false)
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const initialiseMap = useCallback(() => {
|
||||
if (mapRef.current || !mapContainerRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const leaflet = L as LeafletWithHeat
|
||||
const container = mapContainerRef.current
|
||||
|
||||
const map = leaflet
|
||||
.map(container, {
|
||||
worldCopyJump: true,
|
||||
minZoom: 2,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
})
|
||||
.setView([INITIAL_VIEW.lat, INITIAL_VIEW.lng], DEFAULT_ZOOM)
|
||||
|
||||
leaflet
|
||||
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
crossOrigin: true,
|
||||
maxZoom: 19,
|
||||
})
|
||||
.addTo(map)
|
||||
|
||||
const heatLayer =
|
||||
typeof leaflet.heatLayer === 'function'
|
||||
? leaflet.heatLayer([], {
|
||||
radius: 32,
|
||||
blur: 24,
|
||||
maxZoom: 12,
|
||||
gradient: {
|
||||
0.2: '#38bdf8',
|
||||
0.4: '#0ea5e9',
|
||||
0.6: '#fbbf24',
|
||||
0.8: '#fb923c',
|
||||
1: '#ef4444',
|
||||
},
|
||||
})
|
||||
: null
|
||||
|
||||
const userLayer = leaflet.layerGroup().addTo(map)
|
||||
|
||||
if (heatLayer) {
|
||||
heatLayer.addTo(map)
|
||||
}
|
||||
|
||||
const handleClick = (event: LeafletMouseEvent) => {
|
||||
const { lat, lng } = event.latlng
|
||||
if (typeof lat === 'number' && typeof lng === 'number') {
|
||||
onRequestSpot?.({ lat, lng })
|
||||
}
|
||||
}
|
||||
|
||||
map.on('click', handleClick)
|
||||
|
||||
map.whenReady(() => {
|
||||
requestAnimationFrame(() => {
|
||||
map.invalidateSize()
|
||||
})
|
||||
})
|
||||
|
||||
const onResize = () => {
|
||||
requestAnimationFrame(() => {
|
||||
map.invalidateSize()
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
mapRef.current = map
|
||||
heatLayerRef.current = heatLayer
|
||||
userLayerRef.current = userLayer
|
||||
|
||||
return () => {
|
||||
map.off('click', handleClick)
|
||||
window.removeEventListener('resize', onResize)
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
heatLayerRef.current = null
|
||||
userLayerRef.current = null
|
||||
hasCenteredOnUserRef.current = false
|
||||
}
|
||||
}, [onRequestSpot])
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = initialiseMap()
|
||||
|
||||
return () => {
|
||||
if (typeof cleanup === 'function') {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}, [initialiseMap])
|
||||
|
||||
useEffect(() => {
|
||||
const heatLayer = heatLayerRef.current
|
||||
if (!heatLayer) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!heatCells.length) {
|
||||
heatLayer.setLatLngs([])
|
||||
return
|
||||
}
|
||||
|
||||
const maxIntensity = Math.max(...heatCells.map((cell) => cell.intensity)) || 1
|
||||
const heatPoints: HeatPoint[] = heatCells.map((cell) => [
|
||||
cell.lat,
|
||||
cell.lng,
|
||||
Math.max(0.2, cell.intensity / maxIntensity),
|
||||
])
|
||||
|
||||
heatLayer.setLatLngs(heatPoints)
|
||||
}, [heatCells])
|
||||
|
||||
useEffect(() => {
|
||||
const userLayer = userLayerRef.current
|
||||
const map = mapRef.current
|
||||
if (!userLayer || !map) {
|
||||
return
|
||||
}
|
||||
|
||||
userLayer.clearLayers()
|
||||
|
||||
if (!userLocation) {
|
||||
return
|
||||
}
|
||||
|
||||
L.circleMarker([userLocation.lat, userLocation.lng], {
|
||||
radius: 7,
|
||||
color: '#38bdf8',
|
||||
weight: 3,
|
||||
opacity: 0.9,
|
||||
fillColor: '#38bdf8',
|
||||
fillOpacity: 0.35,
|
||||
}).addTo(userLayer)
|
||||
|
||||
if (!hasCenteredOnUserRef.current) {
|
||||
map.setView([userLocation.lat, userLocation.lng], 13, { animate: true })
|
||||
hasCenteredOnUserRef.current = true
|
||||
}
|
||||
}, [userLocation])
|
||||
|
||||
const focusOn = useCallback((position: LatLng, zoom = 14) => {
|
||||
const map = mapRef.current
|
||||
if (!map) {
|
||||
return
|
||||
}
|
||||
map.setView([position.lat, position.lng], zoom, { animate: true })
|
||||
}, [])
|
||||
|
||||
const fitToHeat = useCallback(() => {
|
||||
const map = mapRef.current
|
||||
const heatLayer = heatLayerRef.current
|
||||
if (!map || !heatLayer) {
|
||||
return
|
||||
}
|
||||
const bounds = heatLayer.getBounds?.()
|
||||
if (bounds && bounds.isValid()) {
|
||||
map.fitBounds(bounds.pad(0.25), { animate: true })
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
mapContainerRef,
|
||||
focusOn,
|
||||
fitToHeat,
|
||||
map: mapRef.current,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
const STORAGE_KEY = 'signalmap-theme'
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
function getPreferredTheme(): Theme {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'dark'
|
||||
}
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
return stored
|
||||
}
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
return prefersDark ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => getPreferredTheme())
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme)
|
||||
window.localStorage.setItem(STORAGE_KEY, theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((current) => (current === 'dark' ? 'light' : 'dark'))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
isDark: theme === 'dark',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { LatLng } from '@/types/api'
|
||||
|
||||
function geolocationErrorMessage(error: GeolocationPositionError): string {
|
||||
switch (error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
return 'location.error.permissionDenied'
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
return 'location.error.unavailable'
|
||||
case error.TIMEOUT:
|
||||
return 'location.error.timeout'
|
||||
default:
|
||||
return 'location.error.generic'
|
||||
}
|
||||
}
|
||||
|
||||
export function useUserLocation() {
|
||||
const [location, setLocation] = useState<LatLng | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isRequesting, setIsRequesting] = useState<boolean>(false)
|
||||
const watchIdRef = useRef<number | null>(null)
|
||||
|
||||
const clearWatch = useCallback(() => {
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
return
|
||||
}
|
||||
if (watchIdRef.current !== null) {
|
||||
navigator.geolocation.clearWatch(watchIdRef.current)
|
||||
watchIdRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
setError('location.error.unsupported')
|
||||
setIsRequesting(false)
|
||||
return
|
||||
}
|
||||
|
||||
clearWatch()
|
||||
setIsRequesting(true)
|
||||
setError(null)
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
|
||||
setIsRequesting(false)
|
||||
setError(null)
|
||||
},
|
||||
(geoError) => {
|
||||
setError(geolocationErrorMessage(geoError))
|
||||
setIsRequesting(false)
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 },
|
||||
)
|
||||
|
||||
const watchId = navigator.geolocation.watchPosition(
|
||||
(position) => {
|
||||
setLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
|
||||
setError(null)
|
||||
setIsRequesting(false)
|
||||
},
|
||||
(geoError) => {
|
||||
setError(geolocationErrorMessage(geoError))
|
||||
setIsRequesting(false)
|
||||
},
|
||||
{ enableHighAccuracy: true, maximumAge: 15000, timeout: 10000 },
|
||||
)
|
||||
|
||||
watchIdRef.current = watchId
|
||||
}, [clearWatch])
|
||||
|
||||
useEffect(() => {
|
||||
start()
|
||||
|
||||
return () => {
|
||||
clearWatch()
|
||||
}
|
||||
}, [clearWatch, start])
|
||||
|
||||
return {
|
||||
location,
|
||||
error,
|
||||
isRequesting,
|
||||
refresh: start,
|
||||
}
|
||||
}
|
||||
+49
-32
@@ -5,15 +5,32 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 200 98% 39%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 200 98% 39%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 200 98% 39%;
|
||||
--radius: 0.9rem;
|
||||
}
|
||||
|
||||
:root {
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--background: 222.2 84% 4.9%;
|
||||
--background: 224 47% 8%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 6.5%;
|
||||
--card-foreground: 210 40% 96%;
|
||||
@@ -23,52 +40,52 @@
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 96%;
|
||||
--muted: 223 50% 12%;
|
||||
--muted: 223 47% 10%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 199 89% 62%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 221.6 30% 23%;
|
||||
--input: 221.6 30% 23%;
|
||||
--border: 217 33% 18%;
|
||||
--input: 217 33% 18%;
|
||||
--ring: 199 89% 62%;
|
||||
--radius: 0.8rem;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-h-screen bg-background bg-radial-signal text-foreground antialiased;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
@apply min-h-screen bg-background text-foreground antialiased;
|
||||
background-image: radial-gradient(circle at top, hsla(var(--primary) / 0.1), transparent 45%),
|
||||
radial-gradient(circle at bottom, hsla(var(--destructive) / 0.08), transparent 55%);
|
||||
}
|
||||
|
||||
p {
|
||||
@apply leading-relaxed;
|
||||
#root {
|
||||
@apply min-h-screen;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.leaflet-wrapper,
|
||||
.leaflet-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.leaflet-tooltip {
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
color: #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 15px 35px rgba(2, 6, 23, 0.45);
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
border-top-color: rgba(15, 23, 42, 0.9);
|
||||
border-bottom-color: rgba(15, 23, 42, 0.9);
|
||||
border-left-color: rgba(15, 23, 42, 0.9);
|
||||
border-right-color: rgba(15, 23, 42, 0.9);
|
||||
.leaflet-pane,
|
||||
.leaflet-top,
|
||||
.leaflet-bottom,
|
||||
.leaflet-control-container {
|
||||
z-index: 20;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
import enCommon from '@/locales/en/common.json'
|
||||
import frCommon from '@/locales/fr/common.json'
|
||||
|
||||
const browserLanguage =
|
||||
typeof window !== 'undefined' ? window.navigator.language.split('-')[0]?.toLowerCase() : 'en'
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { common: enCommon },
|
||||
fr: { common: frCommon },
|
||||
},
|
||||
lng: browserLanguage === 'fr' ? 'fr' : 'en',
|
||||
fallbackLng: 'en',
|
||||
defaultNS: 'common',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
export { i18n }
|
||||
|
||||
+12
-10
@@ -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',
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
import '@/index.css'
|
||||
import App from '@/App.tsx'
|
||||
import { i18n } from '@/lib/i18n'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<App />
|
||||
</I18nextProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export type FeedStatus = 'loading' | 'idle' | 'error' | 'posting' | 'refreshing'
|
||||
|
||||
export interface ApiPoint {
|
||||
id: number
|
||||
lat: number
|
||||
lng: number
|
||||
createdAt: string
|
||||
userKey: string
|
||||
}
|
||||
|
||||
export interface ApiDensityCell {
|
||||
lat: number
|
||||
lng: number
|
||||
intensity: number
|
||||
}
|
||||
|
||||
export interface ApiSnapshot {
|
||||
clientKey?: string
|
||||
points?: ApiPoint[]
|
||||
density?: ApiDensityCell[]
|
||||
latestByUser?: ApiPoint[]
|
||||
totals?: {
|
||||
points: number
|
||||
contributors: number
|
||||
}
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type LatLng = {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
Reference in New Issue
Block a user