Add complete server-side internationalization support for non-JavaScript users

This commit extends the existing i18n system to fully support server-side translations
for users who have JavaScript disabled or are using the /table route directly.

Changes:
- Complete server-side Spanish (es-MX) translation support for /table route
- Language selector dropdown in table view with form-based locale switching
- URL parameter support (?locale=es-MX) for direct language selection
- Updated POST form handler to persist locale selection across submissions
- Proper locale detection and fallback for server-rendered pages
- Fixed language selector initialization timing in client-side JS
- Removed unused dependencies (canvas, sharp) to clean up package.json
- Added snowflake emoji to app name in both English and Spanish translations

The /table route now provides a complete non-JavaScript experience in both English
and Spanish, ensuring accessibility for all users regardless of their browser
capabilities or JavaScript preferences.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Code 2025-07-07 12:57:37 -04:00
parent 8b1787ec47
commit 47b495d8eb
6 changed files with 107 additions and 563 deletions

505
package-lock.json generated
View file

@ -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": {

View file

@ -1,9 +1,4 @@
document.addEventListener('DOMContentLoaded', () => {
// Initialize language selector
if (window.i18n) {
window.i18n.createLanguageSelector('language-selector-container');
}
const map = L.map('map').setView([42.96008, -85.67403], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {

View file

@ -31,6 +31,9 @@ class I18nService {
// Trigger translation update after loading
this.updatePageTranslations();
// Create language selector after translations are loaded
this.createLanguageSelector('language-selector-container');
}
/**
@ -226,10 +229,16 @@ class I18nService {
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';
select.title = this.t('common.selectLanguage');
// 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 => {

View file

@ -1,6 +1,6 @@
{
"common": {
"appName": "Great Lakes Ice Report",
"appName": "❄️ Great Lakes Ice Report",
"appNameShort": "Ice Report",
"loading": "Loading...",
"error": "Error",
@ -14,7 +14,8 @@
"login": "Login",
"homepage": "Homepage",
"darkMode": "Toggle dark mode",
"privacyPolicy": "Privacy Policy"
"privacyPolicy": "Privacy Policy",
"selectLanguage": "Select language"
},
"navigation": {
"mapView": "📍 Map View",

View file

@ -1,6 +1,6 @@
{
"common": {
"appName": "Reporte de Hielo de los Grandes Lagos",
"appName": "❄️ Reporte de Hielo de los Grandes Lagos",
"appNameShort": "Reporte de Hielo",
"loading": "Cargando...",
"error": "Error",
@ -14,7 +14,8 @@
"login": "Iniciar sesión",
"homepage": "Página principal",
"darkMode": "Activar modo oscuro",
"privacyPolicy": "Política de privacidad"
"privacyPolicy": "Política de privacidad",
"selectLanguage": "Seleccionar idioma"
},
"navigation": {
"mapView": "📍 Vista del mapa",

View file

@ -209,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));
@ -245,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>
@ -265,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>
` : ''}
@ -316,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>
@ -331,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>
@ -352,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>
@ -377,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>
@ -403,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>
@ -417,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>