Merge pull request 'feature/spanish-localization' (#20) from feature/spanish-localization into main
Reviewed-on: deco/ice#20
This commit is contained in:
commit
c4244aa307
11 changed files with 1103 additions and 570 deletions
505
package-lock.json
generated
505
package-lock.json
generated
|
@ -10,13 +10,11 @@
|
|||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"canvas": "^3.1.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.0.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"sharp": "^0.34.2",
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
|
@ -729,16 +727,6 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
|
||||
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
||||
|
@ -1059,402 +1047,6 @@
|
|||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.2.tgz",
|
||||
"integrity": "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.2.tgz",
|
||||
"integrity": "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz",
|
||||
"integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz",
|
||||
"integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz",
|
||||
"integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz",
|
||||
"integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz",
|
||||
"integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz",
|
||||
"integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz",
|
||||
"integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz",
|
||||
"integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz",
|
||||
"integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.2.tgz",
|
||||
"integrity": "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.2.tgz",
|
||||
"integrity": "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.2.tgz",
|
||||
"integrity": "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.2.tgz",
|
||||
"integrity": "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.2.tgz",
|
||||
"integrity": "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.2.tgz",
|
||||
"integrity": "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.2.tgz",
|
||||
"integrity": "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.2.tgz",
|
||||
"integrity": "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.2.tgz",
|
||||
"integrity": "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.2.tgz",
|
||||
"integrity": "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
|
||||
|
@ -3940,20 +3532,6 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/canvas/-/canvas-3.1.2.tgz",
|
||||
"integrity": "sha512-Z/tzFAcBzoCvJlOSlCnoekh1Gu8YMn0J51+UAuXJAbW1Z6I9l2mZgdD7738MepoeeIcUdDtbMnOg6cC7GJxy/g==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.0.0",
|
||||
"prebuild-install": "^7.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.12.0 || >= 20.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
|
@ -4104,23 +3682,11 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1",
|
||||
"color-string": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
|
@ -4133,18 +3699,9 @@
|
|||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^1.0.0",
|
||||
"simple-swizzle": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/color-support": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
|
@ -8663,47 +8220,6 @@
|
|||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.2.tgz",
|
||||
"integrity": "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"color": "^4.2.3",
|
||||
"detect-libc": "^2.0.4",
|
||||
"semver": "^7.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.2",
|
||||
"@img/sharp-darwin-x64": "0.34.2",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.1.0",
|
||||
"@img/sharp-libvips-darwin-x64": "1.1.0",
|
||||
"@img/sharp-libvips-linux-arm": "1.1.0",
|
||||
"@img/sharp-libvips-linux-arm64": "1.1.0",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.1.0",
|
||||
"@img/sharp-libvips-linux-s390x": "1.1.0",
|
||||
"@img/sharp-libvips-linux-x64": "1.1.0",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.1.0",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.1.0",
|
||||
"@img/sharp-linux-arm": "0.34.2",
|
||||
"@img/sharp-linux-arm64": "0.34.2",
|
||||
"@img/sharp-linux-s390x": "0.34.2",
|
||||
"@img/sharp-linux-x64": "0.34.2",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.2",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.2",
|
||||
"@img/sharp-wasm32": "0.34.2",
|
||||
"@img/sharp-win32-arm64": "0.34.2",
|
||||
"@img/sharp-win32-ia32": "0.34.2",
|
||||
"@img/sharp-win32-x64": "0.34.2"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
@ -8864,21 +8380,6 @@
|
|||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
|
||||
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle/node_modules/is-arrayish": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
|
||||
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
|
@ -9581,7 +9082,7 @@
|
|||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
|
|
|
@ -13,8 +13,9 @@
|
|||
"watch-css": "sass src/scss/main.scss public/style.css --watch --style=expanded --source-map",
|
||||
"dev-with-css": "concurrently \"npm run watch-css\" \"npm run dev\"",
|
||||
"dev-with-css:ts": "concurrently \"npm run watch-css\" \"npm run dev:ts\"",
|
||||
"build": "npm run build:ts && npm run build-css",
|
||||
"build": "npm run build:ts && npm run build-css && npm run copy-i18n",
|
||||
"build:ts": "tsc",
|
||||
"copy-i18n": "mkdir -p dist/i18n/locales && cp -r src/i18n/locales/* dist/i18n/locales/",
|
||||
"test": "jest --runInBand --forceExit",
|
||||
"test:coverage": "jest --coverage",
|
||||
"lint": "eslint src/ tests/",
|
||||
|
|
|
@ -82,7 +82,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
.then(locations => {
|
||||
showMarkers(locations);
|
||||
const countElement = document.getElementById('location-count');
|
||||
countElement.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`;
|
||||
const reportsText = locations.length === 1 ?
|
||||
(window.t ? window.t('map.activeReports') : 'active report') :
|
||||
(window.t ? window.t('map.activeReportsPlural') : 'active reports');
|
||||
countElement.textContent = `${locations.length} ${reportsText}`;
|
||||
|
||||
// Add visual indicator of last update
|
||||
const now = new Date();
|
||||
|
@ -92,11 +95,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
countElement.title = `Last updated: ${timeStr}`;
|
||||
const lastUpdatedText = window.t ? window.t('time.lastUpdated') : 'Last updated:';
|
||||
countElement.title = `${lastUpdatedText} ${timeStr}`;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error fetching locations:', err);
|
||||
document.getElementById('location-count').textContent = 'Error loading locations';
|
||||
const errorText = window.t ? window.t('table.errorLoading') : 'Error loading locations';
|
||||
document.getElementById('location-count').textContent = errorText;
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -242,13 +247,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
.then(location => {
|
||||
// Immediately refresh all locations to show the new one
|
||||
refreshLocations();
|
||||
messageDiv.textContent = 'Location reported successfully!';
|
||||
const successText = window.t ? window.t('form.reportSubmittedMsg') : 'Location reported successfully!';
|
||||
messageDiv.textContent = successText;
|
||||
messageDiv.className = 'message success';
|
||||
locationForm.reset();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error reporting location:', err);
|
||||
messageDiv.textContent = 'Error reporting location.';
|
||||
const errorText = window.t ? window.t('form.submitError') : 'Error reporting location.';
|
||||
messageDiv.textContent = errorText;
|
||||
messageDiv.className = 'message error';
|
||||
})
|
||||
.finally(() => {
|
||||
|
|
276
public/i18n.js
Normal file
276
public/i18n.js
Normal file
|
@ -0,0 +1,276 @@
|
|||
/**
|
||||
* Frontend internationalization (i18n) service
|
||||
* Provides translation support for client-side JavaScript
|
||||
*/
|
||||
|
||||
class I18nService {
|
||||
constructor() {
|
||||
this.translations = new Map();
|
||||
this.defaultLocale = 'en';
|
||||
this.availableLocales = ['en', 'es-MX'];
|
||||
this.currentLocale = this.getStoredLocale() || this.detectBrowserLocale();
|
||||
this.loadTranslations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load translation data from the server
|
||||
*/
|
||||
async loadTranslations() {
|
||||
for (const locale of this.availableLocales) {
|
||||
try {
|
||||
const response = await fetch(`/api/i18n/${locale}`);
|
||||
if (response.ok) {
|
||||
const translations = await response.json();
|
||||
this.translations.set(locale, translations);
|
||||
console.log(`Loaded translations for locale: ${locale}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load translations for locale ${locale}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger translation update after loading
|
||||
this.updatePageTranslations();
|
||||
|
||||
// Create language selector after translations are loaded
|
||||
this.createLanguageSelector('language-selector-container');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a translated string by key path
|
||||
*/
|
||||
t(keyPath, locale = null, params = null) {
|
||||
const targetLocale = locale || this.currentLocale;
|
||||
const translations = this.translations.get(targetLocale) || this.translations.get(this.defaultLocale);
|
||||
|
||||
if (!translations) {
|
||||
console.warn(`No translations found for locale: ${targetLocale}`);
|
||||
return keyPath;
|
||||
}
|
||||
|
||||
const keys = keyPath.split('.');
|
||||
let value = translations;
|
||||
|
||||
for (const key of keys) {
|
||||
value = value?.[key];
|
||||
if (value === undefined) {
|
||||
// Fallback to default locale if key not found
|
||||
if (targetLocale !== this.defaultLocale) {
|
||||
return this.t(keyPath, this.defaultLocale, params);
|
||||
}
|
||||
console.warn(`Translation key not found: ${keyPath} for locale: ${targetLocale}`);
|
||||
return keyPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
console.warn(`Translation value is not a string for key: ${keyPath}`);
|
||||
return keyPath;
|
||||
}
|
||||
|
||||
// Replace parameters if provided
|
||||
if (params) {
|
||||
return this.replaceParams(value, params);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace parameters in translation strings
|
||||
*/
|
||||
replaceParams(text, params) {
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return params[key] || match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current locale and update the UI
|
||||
*/
|
||||
async setLocale(locale) {
|
||||
if (!this.availableLocales.includes(locale)) {
|
||||
console.warn(`Unsupported locale: ${locale}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentLocale = locale;
|
||||
this.storeLocale(locale);
|
||||
|
||||
// Update HTML lang attribute
|
||||
document.documentElement.lang = locale;
|
||||
|
||||
// Update page translations
|
||||
this.updatePageTranslations();
|
||||
|
||||
// Trigger custom event for components to update
|
||||
window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale } }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current locale
|
||||
*/
|
||||
getLocale() {
|
||||
return this.currentLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available locales
|
||||
*/
|
||||
getAvailableLocales() {
|
||||
return [...this.availableLocales];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale display name
|
||||
*/
|
||||
getLocaleDisplayName(locale) {
|
||||
const displayNames = {
|
||||
'en': 'English',
|
||||
'es-MX': 'Español (México)'
|
||||
};
|
||||
return displayNames[locale] || locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect browser's preferred locale
|
||||
*/
|
||||
detectBrowserLocale() {
|
||||
const browserLang = navigator.language || navigator.languages?.[0];
|
||||
|
||||
if (!browserLang) {
|
||||
return this.defaultLocale;
|
||||
}
|
||||
|
||||
// Check for exact match first
|
||||
if (this.availableLocales.includes(browserLang)) {
|
||||
return browserLang;
|
||||
}
|
||||
|
||||
// Check for language match (e.g., "es" matches "es-MX")
|
||||
const languageCode = browserLang.split('-')[0];
|
||||
const matchingLocale = this.availableLocales.find(locale =>
|
||||
locale.startsWith(languageCode)
|
||||
);
|
||||
|
||||
return matchingLocale || this.defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store locale preference in localStorage
|
||||
*/
|
||||
storeLocale(locale) {
|
||||
try {
|
||||
localStorage.setItem('preferredLocale', locale);
|
||||
} catch (error) {
|
||||
console.warn('Failed to store locale preference:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored locale preference from localStorage
|
||||
*/
|
||||
getStoredLocale() {
|
||||
try {
|
||||
return localStorage.getItem('preferredLocale');
|
||||
} catch (error) {
|
||||
console.warn('Failed to get stored locale preference:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update page translations using data-i18n attributes
|
||||
*/
|
||||
updatePageTranslations() {
|
||||
// Update elements with data-i18n attribute
|
||||
document.querySelectorAll('[data-i18n]').forEach(element => {
|
||||
const key = element.getAttribute('data-i18n');
|
||||
const translation = this.t(key);
|
||||
|
||||
if (element.tagName === 'INPUT' && (element.type === 'text' || element.type === 'password')) {
|
||||
element.placeholder = translation;
|
||||
} else if (element.hasAttribute('title')) {
|
||||
element.title = translation;
|
||||
} else {
|
||||
element.textContent = translation;
|
||||
}
|
||||
});
|
||||
|
||||
// Update elements with data-i18n-html attribute (for HTML content)
|
||||
document.querySelectorAll('[data-i18n-html]').forEach(element => {
|
||||
const key = element.getAttribute('data-i18n-html');
|
||||
const translation = this.t(key);
|
||||
element.innerHTML = translation;
|
||||
});
|
||||
|
||||
// Update page title if it has translation
|
||||
const titleElement = document.querySelector('title[data-i18n]');
|
||||
if (titleElement) {
|
||||
const key = titleElement.getAttribute('data-i18n');
|
||||
document.title = this.t(key);
|
||||
}
|
||||
|
||||
// Update meta description if it has translation
|
||||
const metaDescription = document.querySelector('meta[name="description"][data-i18n]');
|
||||
if (metaDescription) {
|
||||
const key = metaDescription.getAttribute('data-i18n');
|
||||
metaDescription.content = this.t(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create language selector dropdown
|
||||
*/
|
||||
createLanguageSelector(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.warn(`Container with id "${containerId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear container first in case it's being recreated
|
||||
container.innerHTML = '';
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.id = 'language-selector';
|
||||
select.className = 'language-selector';
|
||||
|
||||
// Use fallback title if translations not loaded yet
|
||||
const titleText = this.translations.size > 0 ? this.t('common.selectLanguage') : 'Select language';
|
||||
select.title = titleText;
|
||||
|
||||
// Add options for each available locale
|
||||
this.availableLocales.forEach(locale => {
|
||||
const option = document.createElement('option');
|
||||
option.value = locale;
|
||||
option.textContent = this.getLocaleDisplayName(locale);
|
||||
option.selected = locale === this.currentLocale;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
// Add change event listener
|
||||
select.addEventListener('change', (event) => {
|
||||
this.setLocale(event.target.value);
|
||||
});
|
||||
|
||||
container.appendChild(select);
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
window.i18n = new I18nService();
|
||||
|
||||
// Helper function for easier access
|
||||
window.t = function(keyPath, locale = null, params = null) {
|
||||
return window.i18n.t(keyPath, locale, params);
|
||||
};
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.i18n.loadTranslations();
|
||||
});
|
||||
} else {
|
||||
window.i18n.loadTranslations();
|
||||
}
|
|
@ -3,10 +3,10 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Great Lakes Ice Report</title>
|
||||
<title data-i18n="common.appName">Great Lakes Ice Report</title>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="description" content="Community-driven winter road conditions and icy hazards tracker for the Great Lakes region">
|
||||
<meta name="description" content="Community-driven winter road conditions and icy hazards tracker for the Great Lakes region" data-i18n="meta.description">
|
||||
<meta name="theme-color" content="#2196F3">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
|
@ -44,18 +44,24 @@
|
|||
.nojs-fallback { display: block !important; }
|
||||
</style>
|
||||
</noscript>
|
||||
|
||||
<!-- Internationalization -->
|
||||
<script src="i18n.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<div class="header-text">
|
||||
<h1>❄️ Great Lakes Ice Report</h1>
|
||||
<p>Community-reported ICEy road conditions and winter hazards (auto-expire after 48 hours)</p>
|
||||
<h1 data-i18n="common.appName">❄️ Great Lakes Ice Report</h1>
|
||||
<p data-i18n="meta.subtitle">Community-reported ICEy road conditions and winter hazards (auto-expire after 48 hours)</p>
|
||||
</div>
|
||||
<div class="header-controls">
|
||||
<div id="language-selector-container" class="language-selector-container"></div>
|
||||
<button id="theme-toggle" class="theme-toggle js-only" title="Toggle dark mode" data-i18n="common.darkMode">
|
||||
<span class="theme-icon">🌙</span>
|
||||
</button>
|
||||
</div>
|
||||
<button id="theme-toggle" class="theme-toggle js-only" title="Toggle dark mode">
|
||||
<span class="theme-icon">🌙</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
@ -69,10 +75,10 @@
|
|||
</noscript>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Report ICEy Conditions</h2>
|
||||
<h2 data-i18n="form.reportConditions">Report ICEy Conditions</h2>
|
||||
<form id="location-form" method="POST" action="/submit-report">
|
||||
<div class="form-group">
|
||||
<label for="address">Address or Location *</label>
|
||||
<label for="address" data-i18n="form.addressLabel">Address or Location *</label>
|
||||
<div class="autocomplete-container">
|
||||
<input type="text" id="address" name="address" required
|
||||
placeholder="Enter address, intersection (e.g., Main St & Second St, City), or landmark"
|
||||
|
@ -102,8 +108,8 @@ placeholder="Enter address, intersection (e.g., Main St & Second St, City), or l
|
|||
<div class="reports-header">
|
||||
<h2>Current Reports</h2>
|
||||
<div class="view-toggle js-only">
|
||||
<button id="map-view-btn" class="toggle-btn active">📍 Map View</button>
|
||||
<button id="table-view-btn" class="toggle-btn">📋 Table View</button>
|
||||
<button id="map-view-btn" class="toggle-btn active" data-i18n="navigation.mapView">📍 Map View</button>
|
||||
<button id="table-view-btn" class="toggle-btn" data-i18n="navigation.tableView">📋 Table View</button>
|
||||
<a href="/table" class="toggle-btn" style="text-decoration: none; line-height: normal;" title="Server-side view that works without JavaScript">📊 Basic View</a>
|
||||
</div>
|
||||
<noscript>
|
||||
|
|
170
src/i18n/index.ts
Normal file
170
src/i18n/index.ts
Normal file
|
@ -0,0 +1,170 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface TranslationData {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export class I18nService {
|
||||
private translations: Map<string, TranslationData> = new Map();
|
||||
private defaultLocale = 'en';
|
||||
private availableLocales = ['en', 'es-MX'];
|
||||
|
||||
constructor() {
|
||||
this.loadTranslations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all translation files from the locales directory
|
||||
*/
|
||||
private loadTranslations(): void {
|
||||
const localesDir = path.join(__dirname, 'locales');
|
||||
|
||||
for (const locale of this.availableLocales) {
|
||||
try {
|
||||
const filePath = path.join(localesDir, `${locale}.json`);
|
||||
const translationData = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
this.translations.set(locale, translationData);
|
||||
console.log(`Loaded translations for locale: ${locale}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load translations for locale ${locale}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a translated string by key path (e.g., 'common.appName')
|
||||
*/
|
||||
public t(keyPath: string, locale: string = this.defaultLocale, params?: Record<string, string>): string {
|
||||
const translations = this.translations.get(locale) || this.translations.get(this.defaultLocale);
|
||||
|
||||
if (!translations) {
|
||||
console.warn(`No translations found for locale: ${locale}`);
|
||||
return keyPath;
|
||||
}
|
||||
|
||||
const keys = keyPath.split('.');
|
||||
let value: any = translations;
|
||||
|
||||
for (const key of keys) {
|
||||
value = value?.[key];
|
||||
if (value === undefined) {
|
||||
// Fallback to default locale if key not found
|
||||
if (locale !== this.defaultLocale) {
|
||||
return this.t(keyPath, this.defaultLocale, params);
|
||||
}
|
||||
console.warn(`Translation key not found: ${keyPath} for locale: ${locale}`);
|
||||
return keyPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
console.warn(`Translation value is not a string for key: ${keyPath}`);
|
||||
return keyPath;
|
||||
}
|
||||
|
||||
// Replace parameters if provided
|
||||
if (params) {
|
||||
return this.replaceParams(value, params);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace parameters in translation strings (e.g., "Hello {{name}}" with {name: "John"})
|
||||
*/
|
||||
private replaceParams(text: string, params: Record<string, string>): string {
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return params[key] || match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all translations for a specific locale
|
||||
*/
|
||||
public getTranslations(locale: string): TranslationData | null {
|
||||
return this.translations.get(locale) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available locales
|
||||
*/
|
||||
public getAvailableLocales(): string[] {
|
||||
return [...this.availableLocales];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a locale is supported
|
||||
*/
|
||||
public isLocaleSupported(locale: string): boolean {
|
||||
return this.availableLocales.includes(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default locale
|
||||
*/
|
||||
public getDefaultLocale(): string {
|
||||
return this.defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect user's preferred locale from Accept-Language header
|
||||
*/
|
||||
public detectLocale(acceptLanguageHeader?: string): string {
|
||||
if (!acceptLanguageHeader) {
|
||||
return this.defaultLocale;
|
||||
}
|
||||
|
||||
// Parse Accept-Language header (e.g., "es-MX,es;q=0.9,en;q=0.8")
|
||||
const languages = acceptLanguageHeader
|
||||
.split(',')
|
||||
.map(lang => {
|
||||
const parts = lang.trim().split(';');
|
||||
const code = parts[0];
|
||||
const quality = parts[1] ? parseFloat(parts[1].split('=')[1]) : 1;
|
||||
return { code, quality };
|
||||
})
|
||||
.sort((a, b) => b.quality - a.quality);
|
||||
|
||||
// Find the first supported locale
|
||||
for (const lang of languages) {
|
||||
// Check for exact match first (e.g., "es-MX")
|
||||
if (this.isLocaleSupported(lang.code)) {
|
||||
return lang.code;
|
||||
}
|
||||
|
||||
// Check for language match (e.g., "es" matches "es-MX")
|
||||
const languageCode = lang.code.split('-')[0];
|
||||
const matchingLocale = this.availableLocales.find(locale =>
|
||||
locale.startsWith(languageCode)
|
||||
);
|
||||
if (matchingLocale) {
|
||||
return matchingLocale;
|
||||
}
|
||||
}
|
||||
|
||||
return this.defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale display names
|
||||
*/
|
||||
public getLocaleDisplayName(locale: string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
'en': 'English',
|
||||
'es-MX': 'Español (México)'
|
||||
};
|
||||
return displayNames[locale] || locale;
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const i18nService = new I18nService();
|
||||
|
||||
// Helper function for easier access
|
||||
export function t(keyPath: string, locale?: string, params?: Record<string, string>): string {
|
||||
return i18nService.t(keyPath, locale, params);
|
||||
}
|
||||
|
||||
export default i18nService;
|
208
src/i18n/locales/en.json
Normal file
208
src/i18n/locales/en.json
Normal file
|
@ -0,0 +1,208 @@
|
|||
{
|
||||
"common": {
|
||||
"appName": "❄️ Great Lakes Ice Report",
|
||||
"appNameShort": "Ice Report",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"submit": "Submit",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"close": "Close",
|
||||
"refresh": "Refresh",
|
||||
"logout": "Logout",
|
||||
"login": "Login",
|
||||
"homepage": "Homepage",
|
||||
"darkMode": "Toggle dark mode",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
"selectLanguage": "Select language"
|
||||
},
|
||||
"navigation": {
|
||||
"mapView": "📍 Map View",
|
||||
"tableView": "📋 Table View",
|
||||
"basicView": "📊 Basic View",
|
||||
"backToMap": "← Back to Interactive Map",
|
||||
"backToHomepage": "🏠 Back to Homepage",
|
||||
"viewReports": "← View All Reports",
|
||||
"goBack": "← Go Back"
|
||||
},
|
||||
"form": {
|
||||
"reportConditions": "Report ICEy Conditions",
|
||||
"addressLabel": "Address or Location *",
|
||||
"addressPlaceholder": "Enter address, intersection (e.g., Main St & Second St, City), or landmark",
|
||||
"addressExamples": "Examples: \"123 Main St, City\" or \"Main St & Oak Ave, City\" or \"CVS Pharmacy, City\"",
|
||||
"detailsLabel": "Additional Details (Optional)",
|
||||
"detailsPlaceholder": "Number of vehicles, time observed, etc.",
|
||||
"detailsHelp": "Keep descriptions appropriate and relevant to road conditions. Submissions with inappropriate language will be rejected.",
|
||||
"reportLocation": "Report Location",
|
||||
"submitting": "Submitting...",
|
||||
"addressRequired": "Address is required.",
|
||||
"reportSubmitted": "Report Submitted Successfully",
|
||||
"reportSubmittedMsg": "Your ice condition report has been added to the system.",
|
||||
"submissionRejected": "Submission Rejected",
|
||||
"submissionRejectedMsg": "Your description contains inappropriate language and cannot be posted. Please revise your description to focus on road conditions and keep it professional.",
|
||||
"submitError": "Failed to submit report. Please try again."
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"location": "Location",
|
||||
"details": "Details",
|
||||
"reported": "Reported",
|
||||
"timeRemaining": "Time Remaining",
|
||||
"id": "ID",
|
||||
"status": "Status",
|
||||
"address": "Address",
|
||||
"description": "Description",
|
||||
"persistent": "Persistent",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"status": {
|
||||
"active": "ACTIVE",
|
||||
"expired": "EXPIRED",
|
||||
"persistent": "Persistent",
|
||||
"noDetails": "No additional details",
|
||||
"persistentReport": "📌 Persistent Report"
|
||||
},
|
||||
"loading": "Loading reports...",
|
||||
"noReports": "No reports currently available",
|
||||
"errorLoading": "Error loading locations"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "just now",
|
||||
"minuteAgo": "minute ago",
|
||||
"minutesAgo": "minutes ago",
|
||||
"hourAgo": "hour ago",
|
||||
"hoursAgo": "hours ago",
|
||||
"dayAgo": "day ago",
|
||||
"daysAgo": "days ago",
|
||||
"expired": "Expired",
|
||||
"lastUpdated": "Last updated:"
|
||||
},
|
||||
"map": {
|
||||
"redMarkers": "🔴 Red markers: Icy conditions reported",
|
||||
"autoCleanup": "⏰ Auto-cleanup: Reports disappear after 48 hours",
|
||||
"staticMapOverview": "Static Map Overview",
|
||||
"markerLegend": "Red markers: Regular reports | Orange markers: Persistent reports",
|
||||
"currentReports": "Current Reports",
|
||||
"activeReports": "active report",
|
||||
"activeReportsPlural": "active reports",
|
||||
"mapImageError": "Error generating map image"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Great Lakes Ice Report Admin",
|
||||
"adminPanel": "❄️ Great Lakes Ice Report Admin Panel",
|
||||
"adminLogin": "🔐 Admin Login",
|
||||
"passwordLabel": "Admin Password:",
|
||||
"tabs": {
|
||||
"locationReports": "📍 Location Reports",
|
||||
"profanityFilter": "🔒 Profanity Filter"
|
||||
},
|
||||
"stats": {
|
||||
"totalReports": "Total Reports",
|
||||
"active": "Active (48hrs)",
|
||||
"expired": "Expired",
|
||||
"persistent": "Persistent"
|
||||
},
|
||||
"profanity": {
|
||||
"customWords": "Custom Profanity Words",
|
||||
"addWord": "Add Custom Word",
|
||||
"enterWord": "Enter word or phrase",
|
||||
"lowSeverity": "Low Severity",
|
||||
"mediumSeverity": "Medium Severity",
|
||||
"highSeverity": "High Severity",
|
||||
"categoryOptional": "Category (optional)",
|
||||
"addWordBtn": "Add Word",
|
||||
"testFilter": "Test Profanity Filter",
|
||||
"enterText": "Enter text to test",
|
||||
"testBtn": "Test Filter",
|
||||
"textClean": "✅ Text is clean!",
|
||||
"noProfanity": "No profanity detected.",
|
||||
"profanityDetected": "❌ Profanity detected!",
|
||||
"testError": "Error testing text. Please try again.",
|
||||
"headers": {
|
||||
"word": "Word",
|
||||
"severity": "Severity",
|
||||
"category": "Category",
|
||||
"added": "Added"
|
||||
},
|
||||
"noWords": "No custom words added yet",
|
||||
"failedLoad": "Failed to load words",
|
||||
"failedAdd": "Failed to add word:",
|
||||
"failedDelete": "Failed to delete word:"
|
||||
},
|
||||
"auth": {
|
||||
"loginSuccessful": "Login successful",
|
||||
"invalidPassword": "Invalid password",
|
||||
"unauthorized": "Unauthorized",
|
||||
"invalidCredentials": "Invalid credentials",
|
||||
"sessionExpiring": "Your admin session will expire in 5 minutes. Click OK to extend your session, or Cancel to log out now.",
|
||||
"sessionExpired": "Session expired. Please log in again.",
|
||||
"sessionExpiredInactivity": "Session expired due to inactivity.",
|
||||
"sessionEnded": "Session ended by user.",
|
||||
"loggedOut": "Logged out successfully.",
|
||||
"loginFailed": "Login failed. Please try again."
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"addressRequired": "Address is required",
|
||||
"addressTooLong": "Address must be a string with maximum 500 characters",
|
||||
"descriptionTooLong": "Description must be a string with maximum 1000 characters",
|
||||
"invalidLatitude": "Latitude must be a number between -90 and 90",
|
||||
"invalidLongitude": "Longitude must be a number between -180 and 180",
|
||||
"internalError": "Internal server error",
|
||||
"submissionRejected": "Submission rejected",
|
||||
"inappropriateLanguage": "Your description contains inappropriate language and cannot be posted. Please revise your description to focus on road conditions and keep it professional.",
|
||||
"tooManyReports": "Too many location reports submitted",
|
||||
"rateLimited": "You can submit up to 10 location reports every 15 minutes. Please wait before submitting more.",
|
||||
"noResults": "No results found",
|
||||
"configLoadFailed": "Failed to load API configuration, using fallback",
|
||||
"noLocations": "No locations found"
|
||||
},
|
||||
"offline": {
|
||||
"title": "You're Offline",
|
||||
"message": "It looks like you've lost your internet connection. The Great Lakes Ice Report needs an internet connection to show current road conditions and submit new reports.",
|
||||
"whenOnline": "When you're back online, you'll be able to:",
|
||||
"features": [
|
||||
"View current ice condition reports",
|
||||
"Submit new hazard reports",
|
||||
"Get real-time location updates",
|
||||
"Access the interactive map"
|
||||
],
|
||||
"tryAgain": "🔄 Try Again",
|
||||
"needConnection": "This app works best with an internet connection for up-to-date safety information."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy Policy",
|
||||
"effectiveDate": "Effective Date: January 2025",
|
||||
"commitment": "Our Commitment to Privacy",
|
||||
"sections": {
|
||||
"informationCollect": "Information We Collect",
|
||||
"howWeUse": "How We Use Your Information",
|
||||
"informationSharing": "Information Sharing",
|
||||
"dataRetention": "Data Retention",
|
||||
"yourRights": "Your Rights",
|
||||
"security": "Security",
|
||||
"thirdParty": "Third-Party Services",
|
||||
"changes": "Changes to This Policy",
|
||||
"communitySafety": "Community Safety Focus",
|
||||
"contact": "Contact Information"
|
||||
},
|
||||
"noSell": "We do not sell, rent, or share your personal information with third parties.",
|
||||
"anonymousUse": "Anonymous Use: No account required - you can use the service anonymously"
|
||||
},
|
||||
"footer": {
|
||||
"safetyNotice": "Safety Notice: This is a community tool for awareness. Stay safe and",
|
||||
"knowRights": "know your rights",
|
||||
"disclaimer": "This website is for informational purposes only. Verify information independently. Reports are automatically deleted after 48 hours. •"
|
||||
},
|
||||
"nojs": {
|
||||
"notice": "JavaScript is disabled. For the best experience with interactive maps, please enable JavaScript.",
|
||||
"viewTable": "Click here to view the table-only version →",
|
||||
"viewDetailed": "View Detailed Table Format →"
|
||||
},
|
||||
"meta": {
|
||||
"description": "Community-driven winter road conditions and icy hazards tracker for the Great Lakes region",
|
||||
"subtitle": "Community-reported ICEy road conditions and winter hazards (auto-expire after 48 hours)",
|
||||
"adminDescription": "Admin panel for Great Lakes Ice Report - manage winter road condition reports"
|
||||
}
|
||||
}
|
208
src/i18n/locales/es-MX.json
Normal file
208
src/i18n/locales/es-MX.json
Normal file
|
@ -0,0 +1,208 @@
|
|||
{
|
||||
"common": {
|
||||
"appName": "❄️ Reporte de Hielo de los Grandes Lagos",
|
||||
"appNameShort": "Reporte de Hielo",
|
||||
"loading": "Cargando...",
|
||||
"error": "Error",
|
||||
"success": "Éxito",
|
||||
"submit": "Enviar",
|
||||
"cancel": "Cancelar",
|
||||
"back": "Atrás",
|
||||
"close": "Cerrar",
|
||||
"refresh": "Actualizar",
|
||||
"logout": "Cerrar sesión",
|
||||
"login": "Iniciar sesión",
|
||||
"homepage": "Página principal",
|
||||
"darkMode": "Activar modo oscuro",
|
||||
"privacyPolicy": "Política de privacidad",
|
||||
"selectLanguage": "Seleccionar idioma"
|
||||
},
|
||||
"navigation": {
|
||||
"mapView": "📍 Vista del mapa",
|
||||
"tableView": "📋 Vista de tabla",
|
||||
"basicView": "📊 Vista básica",
|
||||
"backToMap": "← Volver al mapa interactivo",
|
||||
"backToHomepage": "🏠 Volver a la página principal",
|
||||
"viewReports": "← Ver todos los reportes",
|
||||
"goBack": "← Regresar"
|
||||
},
|
||||
"form": {
|
||||
"reportConditions": "Reportar condiciones de hielo",
|
||||
"addressLabel": "Dirección o ubicación *",
|
||||
"addressPlaceholder": "Ingrese dirección, intersección (ej., Calle Principal y Segunda Calle, Ciudad), o punto de referencia",
|
||||
"addressExamples": "Ejemplos: \"123 Calle Principal, Ciudad\" o \"Calle Principal y Av. Roble, Ciudad\" o \"Farmacia CVS, Ciudad\"",
|
||||
"detailsLabel": "Detalles adicionales (opcional)",
|
||||
"detailsPlaceholder": "Número de vehículos, hora observada, etc.",
|
||||
"detailsHelp": "Mantenga las descripciones apropiadas y relevantes a las condiciones del camino. Las publicaciones con lenguaje inapropiado serán rechazadas.",
|
||||
"reportLocation": "Reportar ubicación",
|
||||
"submitting": "Enviando...",
|
||||
"addressRequired": "La dirección es requerida.",
|
||||
"reportSubmitted": "Reporte enviado exitosamente",
|
||||
"reportSubmittedMsg": "Su reporte de condiciones de hielo ha sido agregado al sistema.",
|
||||
"submissionRejected": "Envío rechazado",
|
||||
"submissionRejectedMsg": "Su descripción contiene lenguaje inapropiado y no puede ser publicada. Por favor revise su descripción para enfocarse en las condiciones del camino y manténgala profesional.",
|
||||
"submitError": "Error al enviar el reporte. Por favor intente nuevamente."
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"location": "Ubicación",
|
||||
"details": "Detalles",
|
||||
"reported": "Reportado",
|
||||
"timeRemaining": "Tiempo restante",
|
||||
"id": "ID",
|
||||
"status": "Estado",
|
||||
"address": "Dirección",
|
||||
"description": "Descripción",
|
||||
"persistent": "Persistente",
|
||||
"actions": "Acciones"
|
||||
},
|
||||
"status": {
|
||||
"active": "ACTIVO",
|
||||
"expired": "EXPIRADO",
|
||||
"persistent": "Persistente",
|
||||
"noDetails": "Sin detalles adicionales",
|
||||
"persistentReport": "📌 Reporte persistente"
|
||||
},
|
||||
"loading": "Cargando reportes...",
|
||||
"noReports": "No hay reportes disponibles actualmente",
|
||||
"errorLoading": "Error al cargar ubicaciones"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "justo ahora",
|
||||
"minuteAgo": "hace un minuto",
|
||||
"minutesAgo": "minutos atrás",
|
||||
"hourAgo": "hace una hora",
|
||||
"hoursAgo": "horas atrás",
|
||||
"dayAgo": "hace un día",
|
||||
"daysAgo": "días atrás",
|
||||
"expired": "Expirado",
|
||||
"lastUpdated": "Última actualización:"
|
||||
},
|
||||
"map": {
|
||||
"redMarkers": "🔴 Marcadores rojos: Condiciones de hielo reportadas",
|
||||
"autoCleanup": "⏰ Limpieza automática: Los reportes desaparecen después de 48 horas",
|
||||
"staticMapOverview": "Resumen del mapa estático",
|
||||
"markerLegend": "Marcadores rojos: Reportes regulares | Marcadores naranjas: Reportes persistentes",
|
||||
"currentReports": "Reportes actuales",
|
||||
"activeReports": "reporte activo",
|
||||
"activeReportsPlural": "reportes activos",
|
||||
"mapImageError": "Error al generar imagen del mapa"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administrador de Reporte de Hielo de los Grandes Lagos",
|
||||
"adminPanel": "❄️ Panel de administración de Reporte de Hielo de los Grandes Lagos",
|
||||
"adminLogin": "🔐 Inicio de sesión de administrador",
|
||||
"passwordLabel": "Contraseña de administrador:",
|
||||
"tabs": {
|
||||
"locationReports": "📍 Reportes de ubicación",
|
||||
"profanityFilter": "🔒 Filtro de obscenidades"
|
||||
},
|
||||
"stats": {
|
||||
"totalReports": "Reportes totales",
|
||||
"active": "Activo (48h)",
|
||||
"expired": "Expirado",
|
||||
"persistent": "Persistente"
|
||||
},
|
||||
"profanity": {
|
||||
"customWords": "Palabras obscenas personalizadas",
|
||||
"addWord": "Agregar palabra personalizada",
|
||||
"enterWord": "Ingrese palabra o frase",
|
||||
"lowSeverity": "Severidad baja",
|
||||
"mediumSeverity": "Severidad media",
|
||||
"highSeverity": "Severidad alta",
|
||||
"categoryOptional": "Categoría (opcional)",
|
||||
"addWordBtn": "Agregar palabra",
|
||||
"testFilter": "Probar filtro de obscenidades",
|
||||
"enterText": "Ingrese texto para probar",
|
||||
"testBtn": "Probar filtro",
|
||||
"textClean": "✅ ¡El texto está limpio!",
|
||||
"noProfanity": "No se detectaron obscenidades.",
|
||||
"profanityDetected": "❌ ¡Obscenidades detectadas!",
|
||||
"testError": "Error al probar el texto. Por favor intente nuevamente.",
|
||||
"headers": {
|
||||
"word": "Palabra",
|
||||
"severity": "Severidad",
|
||||
"category": "Categoría",
|
||||
"added": "Agregado"
|
||||
},
|
||||
"noWords": "No se han agregado palabras personalizadas aún",
|
||||
"failedLoad": "Error al cargar palabras",
|
||||
"failedAdd": "Error al agregar palabra:",
|
||||
"failedDelete": "Error al eliminar palabra:"
|
||||
},
|
||||
"auth": {
|
||||
"loginSuccessful": "Inicio de sesión exitoso",
|
||||
"invalidPassword": "Contraseña inválida",
|
||||
"unauthorized": "No autorizado",
|
||||
"invalidCredentials": "Credenciales inválidas",
|
||||
"sessionExpiring": "Su sesión de administrador expirará en 5 minutos. Haga clic en Aceptar para extender su sesión, o Cancelar para cerrar sesión ahora.",
|
||||
"sessionExpired": "Sesión expirada. Por favor inicie sesión nuevamente.",
|
||||
"sessionExpiredInactivity": "Sesión expirada por inactividad.",
|
||||
"sessionEnded": "Sesión terminada por el usuario.",
|
||||
"loggedOut": "Sesión cerrada exitosamente.",
|
||||
"loginFailed": "Error al iniciar sesión. Por favor intente nuevamente."
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"addressRequired": "La dirección es requerida",
|
||||
"addressTooLong": "La dirección debe ser una cadena con máximo 500 caracteres",
|
||||
"descriptionTooLong": "La descripción debe ser una cadena con máximo 1000 caracteres",
|
||||
"invalidLatitude": "La latitud debe ser un número entre -90 y 90",
|
||||
"invalidLongitude": "La longitud debe ser un número entre -180 y 180",
|
||||
"internalError": "Error interno del servidor",
|
||||
"submissionRejected": "Envío rechazado",
|
||||
"inappropriateLanguage": "Su descripción contiene lenguaje inapropiado y no puede ser publicada. Por favor revise su descripción para enfocarse en las condiciones del camino y manténgala profesional.",
|
||||
"tooManyReports": "Demasiados reportes de ubicación enviados",
|
||||
"rateLimited": "Puede enviar hasta 10 reportes de ubicación cada 15 minutos. Por favor espere antes de enviar más.",
|
||||
"noResults": "No se encontraron resultados",
|
||||
"configLoadFailed": "Error al cargar la configuración de la API, usando respaldo",
|
||||
"noLocations": "No se encontraron ubicaciones"
|
||||
},
|
||||
"offline": {
|
||||
"title": "Está desconectado",
|
||||
"message": "Parece que ha perdido su conexión a internet. El Reporte de Hielo de los Grandes Lagos necesita una conexión a internet para mostrar las condiciones actuales del camino y enviar nuevos reportes.",
|
||||
"whenOnline": "Cuando esté en línea nuevamente, podrá:",
|
||||
"features": [
|
||||
"Ver reportes actuales de condiciones de hielo",
|
||||
"Enviar nuevos reportes de peligros",
|
||||
"Obtener actualizaciones de ubicación en tiempo real",
|
||||
"Acceder al mapa interactivo"
|
||||
],
|
||||
"tryAgain": "🔄 Intentar nuevamente",
|
||||
"needConnection": "Esta aplicación funciona mejor con una conexión a internet para información de seguridad actualizada."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Política de privacidad",
|
||||
"effectiveDate": "Fecha efectiva: Enero 2025",
|
||||
"commitment": "Nuestro compromiso con la privacidad",
|
||||
"sections": {
|
||||
"informationCollect": "Información que recopilamos",
|
||||
"howWeUse": "Cómo usamos su información",
|
||||
"informationSharing": "Compartir información",
|
||||
"dataRetention": "Retención de datos",
|
||||
"yourRights": "Sus derechos",
|
||||
"security": "Seguridad",
|
||||
"thirdParty": "Servicios de terceros",
|
||||
"changes": "Cambios a esta política",
|
||||
"communitySafety": "Enfoque en la seguridad comunitaria",
|
||||
"contact": "Información de contacto"
|
||||
},
|
||||
"noSell": "No vendemos, alquilamos o compartimos su información personal con terceros.",
|
||||
"anonymousUse": "Uso anónimo: No se requiere cuenta - puede usar el servicio de forma anónima"
|
||||
},
|
||||
"footer": {
|
||||
"safetyNotice": "Aviso de seguridad: Esta es una herramienta comunitaria para concienciación. Manténgase seguro y",
|
||||
"knowRights": "conozca sus derechos",
|
||||
"disclaimer": "Este sitio web es solo para fines informativos. Verifique la información independientemente. Los reportes se eliminan automáticamente después de 48 horas. •"
|
||||
},
|
||||
"nojs": {
|
||||
"notice": "JavaScript está deshabilitado. Para la mejor experiencia con mapas interactivos, por favor habilite JavaScript.",
|
||||
"viewTable": "Haga clic aquí para ver la versión solo de tabla →",
|
||||
"viewDetailed": "Ver formato de tabla detallada →"
|
||||
},
|
||||
"meta": {
|
||||
"description": "Rastreador comunitario de condiciones invernales de carreteras y peligros de hielo en la región de los Grandes Lagos",
|
||||
"subtitle": "Condiciones de carreteras con hielo y peligros invernales reportados por la comunidad (expiran automáticamente después de 48 horas)",
|
||||
"adminDescription": "Panel de administración para Reporte de Hielo de los Grandes Lagos - gestionar reportes de condiciones invernales de carreteras"
|
||||
}
|
||||
}
|
76
src/routes/i18n.ts
Normal file
76
src/routes/i18n.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import { i18nService } from '../i18n';
|
||||
|
||||
export function createI18nRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Get translations for a specific locale
|
||||
* GET /api/i18n/:locale
|
||||
*/
|
||||
router.get('/:locale', (req: Request, res: Response): void => {
|
||||
const { locale } = req.params;
|
||||
|
||||
// Validate locale
|
||||
if (!i18nService.isLocaleSupported(locale)) {
|
||||
res.status(400).json({
|
||||
error: 'Unsupported locale',
|
||||
supportedLocales: i18nService.getAvailableLocales()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get translations for the locale
|
||||
const translations = i18nService.getTranslations(locale);
|
||||
|
||||
if (!translations) {
|
||||
res.status(404).json({
|
||||
error: 'Translations not found for locale',
|
||||
locale
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Set appropriate headers for caching
|
||||
res.set({
|
||||
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
res.json(translations);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get available locales and their display names
|
||||
* GET /api/i18n
|
||||
*/
|
||||
router.get('/', (req: Request, res: Response): void => {
|
||||
const locales = i18nService.getAvailableLocales().map(locale => ({
|
||||
code: locale,
|
||||
name: i18nService.getLocaleDisplayName(locale)
|
||||
}));
|
||||
|
||||
res.json({
|
||||
default: i18nService.getDefaultLocale(),
|
||||
available: locales
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Detect user's preferred locale based on Accept-Language header
|
||||
* GET /api/i18n/detect
|
||||
*/
|
||||
router.get('/detect', (req: Request, res: Response): void => {
|
||||
const acceptLanguage = req.get('Accept-Language');
|
||||
const detectedLocale = i18nService.detectLocale(acceptLanguage);
|
||||
|
||||
res.json({
|
||||
detected: detectedLocale,
|
||||
displayName: i18nService.getLocaleDisplayName(detectedLocale)
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createI18nRoutes;
|
|
@ -21,6 +21,30 @@
|
|||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.language-selector-container {
|
||||
.language-selector {
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $border-radius-sm;
|
||||
background-color: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
font-size: $font-size-sm;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary-color;
|
||||
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
|
||||
|
|
158
src/server.ts
158
src/server.ts
|
@ -14,11 +14,13 @@ dotenv.config();
|
|||
import DatabaseService from './services/DatabaseService';
|
||||
import ProfanityFilterService from './services/ProfanityFilterService';
|
||||
import MapImageService from './services/MapImageService';
|
||||
import { i18nService } from './i18n';
|
||||
|
||||
// Import route modules
|
||||
import configRoutes from './routes/config';
|
||||
import locationRoutes from './routes/locations';
|
||||
import adminRoutes from './routes/admin';
|
||||
import i18nRoutes from './routes/i18n';
|
||||
|
||||
|
||||
const app: Application = express();
|
||||
|
@ -29,6 +31,22 @@ app.use(cors());
|
|||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// Locale detection middleware
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
// Detect user's preferred locale from Accept-Language header or cookie
|
||||
const cookieLocale = req.headers.cookie?.split(';')
|
||||
.find(c => c.trim().startsWith('locale='))?.split('=')[1];
|
||||
|
||||
const detectedLocale = cookieLocale || i18nService.detectLocale(req.get('Accept-Language'));
|
||||
|
||||
// Add locale to request object for use in routes
|
||||
(req as any).locale = detectedLocale;
|
||||
(req as any).t = (key: string, params?: Record<string, string>) =>
|
||||
i18nService.t(key, detectedLocale, params);
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Database and services setup
|
||||
const databaseService = new DatabaseService();
|
||||
let profanityFilter: ProfanityFilterService | FallbackFilter;
|
||||
|
@ -179,6 +197,7 @@ function setupRoutes(): void {
|
|||
app.use('/api/config', configRoutes());
|
||||
app.use('/api/locations', locationRoutes(locationModel, profanityFilter));
|
||||
app.use('/api/admin', adminRoutes(locationModel, profanityWordModel, profanityFilter, authenticateAdmin));
|
||||
app.use('/api/i18n', i18nRoutes());
|
||||
|
||||
// Static page routes
|
||||
app.get('/', (req: Request, res: Response): void => {
|
||||
|
@ -190,14 +209,27 @@ function setupRoutes(): void {
|
|||
app.get('/table', async (req: Request, res: Response): Promise<void> => {
|
||||
console.log('Serving table view for non-JS users');
|
||||
try {
|
||||
// Get locale from query parameter or use detected locale
|
||||
const requestedLocale = req.query.locale as string;
|
||||
const locale = requestedLocale && i18nService.isLocaleSupported(requestedLocale)
|
||||
? requestedLocale
|
||||
: (req as any).locale;
|
||||
|
||||
// Helper function for translations
|
||||
const t = (key: string) => i18nService.t(key, locale);
|
||||
|
||||
const locations = await locationModel.getActive();
|
||||
|
||||
const formatTimeRemaining = (createdAt: string): string => {
|
||||
const formatTimeRemaining = (createdAt: string, isPersistent?: boolean): string => {
|
||||
if (isPersistent) {
|
||||
return t('time.persistent');
|
||||
}
|
||||
|
||||
const created = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const diffMs = (48 * 60 * 60 * 1000) - (now.getTime() - created.getTime());
|
||||
|
||||
if (diffMs <= 0) return 'Expired';
|
||||
if (diffMs <= 0) return t('time.expired');
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
@ -226,19 +258,32 @@ function setupRoutes(): void {
|
|||
<tr>
|
||||
<td style="text-align: center; font-weight: bold;">${index + 1}</td>
|
||||
<td>${escapeHtml(location.address)}</td>
|
||||
<td>${escapeHtml(location.description || 'No additional details')}</td>
|
||||
<td>${location.created_at ? formatDate(location.created_at) : 'Unknown'}</td>
|
||||
<td>${location.persistent ? 'Persistent' : (location.created_at ? formatTimeRemaining(location.created_at) : 'Unknown')}</td>
|
||||
<td>${escapeHtml(location.description || t('table.status.noDetails'))}</td>
|
||||
<td>${location.created_at ? formatDate(location.created_at) : t('common.loading')}</td>
|
||||
<td style="text-align: center;">${location.created_at ? formatTimeRemaining(location.created_at, !!location.persistent) : t('common.loading')}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Generate language selector form for non-JS users
|
||||
const languageSelectorForm = `
|
||||
<form method="get" action="/table" style="display: inline-block; margin-left: 10px;">
|
||||
<select name="locale" onchange="this.form.submit();" style="padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
<option value="en" ${locale === 'en' ? 'selected' : ''}>English</option>
|
||||
<option value="es-MX" ${locale === 'es-MX' ? 'selected' : ''}>Español (México)</option>
|
||||
</select>
|
||||
<noscript>
|
||||
<input type="submit" value="${t('common.submit')}" style="margin-left: 5px; padding: 4px 8px;">
|
||||
</noscript>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="${locale}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Great Lakes Ice Report - Table View</title>
|
||||
<title>${t('common.appName')} - ${t('navigation.tableView')}</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
@ -246,48 +291,52 @@ function setupRoutes(): void {
|
|||
<header>
|
||||
<div class="header-content">
|
||||
<div class="header-text">
|
||||
<h1>❄️ Great Lakes Ice Report</h1>
|
||||
<p>Community-reported ICEy road conditions and winter hazards</p>
|
||||
<h1>${t('common.appName')}</h1>
|
||||
<p>${t('meta.subtitle')}</p>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
${languageSelectorForm}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<div class="form-section">
|
||||
<h2>Report ICEy Conditions</h2>
|
||||
<h2>${t('form.reportConditions')}</h2>
|
||||
<form method="POST" action="/submit-report">
|
||||
<input type="hidden" name="locale" value="${locale}">
|
||||
<div class="form-group">
|
||||
<label for="address">Address or Location *</label>
|
||||
<label for="address">${t('form.addressLabel')}</label>
|
||||
<input type="text" id="address" name="address" required
|
||||
placeholder="Enter address, intersection, or landmark">
|
||||
<small class="input-help">Examples: "123 Main St, City" or "Main St & Oak Ave, City"</small>
|
||||
placeholder="${t('form.addressPlaceholder')}">
|
||||
<small class="input-help">${t('form.addressExamples')}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Additional Details (Optional)</label>
|
||||
<label for="description">${t('form.detailsLabel')}</label>
|
||||
<textarea id="description" name="description" rows="3"
|
||||
placeholder="Number of vehicles, time observed, etc."></textarea>
|
||||
<small class="input-help">Keep descriptions appropriate and relevant to road conditions.</small>
|
||||
placeholder="${t('form.detailsPlaceholder')}"></textarea>
|
||||
<small class="input-help">${t('form.detailsHelp')}</small>
|
||||
</div>
|
||||
|
||||
<button type="submit">Report Location</button>
|
||||
<button type="submit">${t('form.reportLocation')}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="map-section">
|
||||
<div class="reports-header">
|
||||
<h2>Current Reports (${locations.length} active)</h2>
|
||||
<p><a href="/">← Back to Interactive Map</a></p>
|
||||
<h2>${t('map.currentReports')} (${locations.length} ${locations.length === 1 ? t('map.activeReports') : t('map.activeReportsPlural')})</h2>
|
||||
<p><a href="/">${t('navigation.backToMap')}</a></p>
|
||||
</div>
|
||||
|
||||
${locations.length > 0 ? `
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<h3>Static Map Overview</h3>
|
||||
<h3>${t('map.staticMapOverview')}</h3>
|
||||
<img src="/map-image.png?width=800&height=400"
|
||||
alt="Static map showing ice report locations"
|
||||
alt="${t('map.staticMapOverview')}"
|
||||
style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<p style="font-size: 14px; color: #666; margin-top: 8px;">
|
||||
Red markers: Regular reports | Orange markers: Persistent reports
|
||||
${t('map.markerLegend')}
|
||||
</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
@ -297,14 +346,14 @@ function setupRoutes(): void {
|
|||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Location</th>
|
||||
<th>Details</th>
|
||||
<th>Reported</th>
|
||||
<th>Time Remaining</th>
|
||||
<th>${t('table.headers.location')}</th>
|
||||
<th>${t('table.headers.details')}</th>
|
||||
<th>${t('table.headers.reported')}</th>
|
||||
<th>${t('table.headers.timeRemaining')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows || '<tr><td colspan="5">No reports currently available</td></tr>'}
|
||||
${tableRows || `<tr><td colspan="5">${t('table.noReports')}</td></tr>`}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -312,9 +361,9 @@ function setupRoutes(): void {
|
|||
</div>
|
||||
|
||||
<footer>
|
||||
<p><strong>Safety Notice:</strong> This is a community tool for awareness. Stay safe and verify information independently.</p>
|
||||
<p><strong>${t('footer.safetyNotice')}</strong> <a href="https://www.aclu.org/know-your-rights/immigrants-rights" target="_blank" rel="noopener noreferrer">${t('footer.knowRights')}</a>.</p>
|
||||
<div class="disclaimer">
|
||||
<small>Reports are automatically deleted after 48 hours. • <a href="/privacy">Privacy Policy</a></small>
|
||||
<small>${t('footer.disclaimer')} <a href="/privacy">${t('common.privacyPolicy')}</a></small>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
@ -333,18 +382,26 @@ function setupRoutes(): void {
|
|||
app.post('/submit-report', async (req: Request, res: Response): Promise<void> => {
|
||||
console.log('Handling form submission for non-JS users');
|
||||
|
||||
const { address, description } = req.body;
|
||||
const { address, description, locale: formLocale } = req.body;
|
||||
|
||||
// Get locale from form or use detected locale
|
||||
const locale = formLocale && i18nService.isLocaleSupported(formLocale)
|
||||
? formLocale
|
||||
: (req as any).locale;
|
||||
|
||||
// Helper function for translations
|
||||
const t = (key: string) => i18nService.t(key, locale);
|
||||
|
||||
// Validate input
|
||||
if (!address || typeof address !== 'string' || address.trim().length === 0) {
|
||||
res.status(400).send(`
|
||||
<html>
|
||||
<head><title>Error</title><link rel="stylesheet" href="style.css"></head>
|
||||
<html lang="${locale}">
|
||||
<head><title>${t('common.error')}</title><link rel="stylesheet" href="style.css"></head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Error</h1>
|
||||
<p>Address is required.</p>
|
||||
<p><a href="/table">← Go Back</a></p>
|
||||
<h1>${t('common.error')}</h1>
|
||||
<p>${t('form.addressRequired')}</p>
|
||||
<p><a href="/table?locale=${locale}">${t('navigation.goBack')}</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -358,14 +415,13 @@ function setupRoutes(): void {
|
|||
const analysis = profanityFilter.analyzeProfanity(description);
|
||||
if (analysis.hasProfanity) {
|
||||
res.status(400).send(`
|
||||
<html>
|
||||
<head><title>Submission Rejected</title><link rel="stylesheet" href="style.css"></head>
|
||||
<html lang="${locale}">
|
||||
<head><title>${t('form.submissionRejected')}</title><link rel="stylesheet" href="style.css"></head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Submission Rejected</h1>
|
||||
<p>Your description contains inappropriate language and cannot be posted.</p>
|
||||
<p>Please revise your description to focus on road conditions and keep it professional.</p>
|
||||
<p><a href="/table">← Go Back</a></p>
|
||||
<h1>${t('form.submissionRejected')}</h1>
|
||||
<p>${t('errors.inappropriateLanguage')}</p>
|
||||
<p><a href="/table?locale=${locale}">${t('navigation.goBack')}</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -384,13 +440,13 @@ function setupRoutes(): void {
|
|||
});
|
||||
|
||||
res.send(`
|
||||
<html>
|
||||
<head><title>Report Submitted</title><link rel="stylesheet" href="style.css"></head>
|
||||
<html lang="${locale}">
|
||||
<head><title>${t('form.reportSubmitted')}</title><link rel="stylesheet" href="style.css"></head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>✅ Report Submitted Successfully</h1>
|
||||
<p>Your ice condition report has been added to the system.</p>
|
||||
<p><a href="/table">← View All Reports</a></p>
|
||||
<h1>✅ ${t('form.reportSubmitted')}</h1>
|
||||
<p>${t('form.reportSubmittedMsg')}</p>
|
||||
<p><a href="/table?locale=${locale}">${t('navigation.viewReports')}</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -398,13 +454,13 @@ function setupRoutes(): void {
|
|||
} catch (err) {
|
||||
console.error('Error creating location:', err);
|
||||
res.status(500).send(`
|
||||
<html>
|
||||
<head><title>Error</title><link rel="stylesheet" href="style.css"></head>
|
||||
<html lang="${locale}">
|
||||
<head><title>${t('common.error')}</title><link rel="stylesheet" href="style.css"></head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Error</h1>
|
||||
<p>Failed to submit report. Please try again.</p>
|
||||
<p><a href="/table">← Go Back</a></p>
|
||||
<h1>${t('common.error')}</h1>
|
||||
<p>${t('form.submitError')}</p>
|
||||
<p><a href="/table?locale=${locale}">${t('navigation.goBack')}</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue