From ecb73e1684b42265af3f8d93541600e4d0f9c414 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Tue, 24 Jun 2025 16:14:28 +0200 Subject: Implement i18n Closes #18 --- package-lock.json | 100 +++++++++++++++++++++++++++++- package.json | 7 ++- src/frontend/app/i18n/index.ts | 31 +++++++++ src/frontend/app/i18n/locales/en-GB.json | 46 ++++++++++++++ src/frontend/app/i18n/locales/es-ES.json | 46 ++++++++++++++ src/frontend/app/i18n/locales/gl-ES.json | 46 ++++++++++++++ src/frontend/app/root.css | 1 + src/frontend/app/root.tsx | 2 + src/frontend/app/routes/estimates-$id.tsx | 2 + src/frontend/app/routes/map.tsx | 2 + src/frontend/app/routes/settings.tsx | 69 ++++++++++++--------- src/frontend/app/routes/stoplist.tsx | 16 +---- 12 files changed, 324 insertions(+), 44 deletions(-) create mode 100644 src/frontend/app/i18n/index.ts create mode 100644 src/frontend/app/i18n/locales/en-GB.json create mode 100644 src/frontend/app/i18n/locales/es-ES.json create mode 100644 src/frontend/app/i18n/locales/gl-ES.json diff --git a/package-lock.json b/package-lock.json index 07400f9..07872b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,11 @@ "workspaces": [ "src/frontend" ], + "dependencies": { + "i18next": "^25.2.1", + "i18next-browser-languagedetector": "^8.2.0", + "react-i18next": "^15.5.3" + }, "devDependencies": { "concurrently": "^9.1.2", "prettier": "^3.5.3" @@ -451,6 +456,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -4092,6 +4106,15 @@ "node": ">=12" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -4109,6 +4132,46 @@ "node": ">= 0.8" } }, + "node_modules/i18next": { + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.2.1.tgz", + "integrity": "sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5342,6 +5405,32 @@ "react": "^19.1.0" } }, + "node_modules/react-i18next": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.3.tgz", + "integrity": "sha512-ypYmOKOnjqPEJZO4m1BI0kS8kWqkBNsKYyhVUfij0gvjy9xJNoG/VcGkxq5dRlVwzmrmY1BQMAmpbbUBLwC4Kw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-leaflet": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", @@ -6262,7 +6351,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6615,6 +6704,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/vt-pbf": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", diff --git a/package.json b/package.json index 81ba10f..24814f6 100644 --- a/package.json +++ b/package.json @@ -18,5 +18,10 @@ }, "workspaces": [ "src/frontend" - ] + ], + "dependencies": { + "i18next": "^25.2.1", + "i18next-browser-languagedetector": "^8.2.0", + "react-i18next": "^15.5.3" + } } diff --git a/src/frontend/app/i18n/index.ts b/src/frontend/app/i18n/index.ts new file mode 100644 index 0000000..a7ba6aa --- /dev/null +++ b/src/frontend/app/i18n/index.ts @@ -0,0 +1,31 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import esES from './locales/es-ES.json'; +import glES from './locales/gl-ES.json'; +import enGB from './locales/en-GB.json'; + +// Add more languages as needed +const resources = { + 'es-ES': { translation: esES }, + 'gl-ES': { translation: glES }, + 'en-GB': { translation: enGB }, +}; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'es-ES', + interpolation: { + escapeValue: false, + }, + supportedLngs: ['es-ES', 'gl-ES', 'en-GB'], + detection: { + order: ['querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag'], + caches: ['localStorage', 'cookie'], + }, + }); + +export default i18n; diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json new file mode 100644 index 0000000..f53a802 --- /dev/null +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -0,0 +1,46 @@ +{ + "about": { + "title": "About UrbanoVigo Web", + "description": "Web app to find stops and arrival times for Vigo's urban buses, Spain.", + "credits": "Credits", + "github": "Code on GitHub", + "developed_by": "Developed by", + "data_source_prefix": "Data from", + "data_source_middle": "under license", + "settings": "Settings", + "theme": "Mode:", + "theme_light": "Light", + "theme_dark": "Dark", + "table_style": "Table style:", + "table_style_regular": "Show in order", + "table_style_grouped": "Group by line", + "map_position_mode": "Map position:", + "map_position_gps": "GPS position", + "map_position_last": "Where I left it", + "details_summary": "What does this mean?", + "details_table": "The timetable can be shown in two ways:", + "details_regular": "Stops are shown in the order they are visited. Apps like Infobus (Vitrasa) use this style.", + "details_grouped": "Stops are grouped by bus line. Apps like iTranvias (A Coruña) or Moovit (more or less) use this style." + }, + "stoplist": { + "search_placeholder": "Search stop...", + "favourites": "Favourites", + "recents": "Recent" + }, + "estimates": { + "minutes": "min", + "meters": "m", + "edit": "Edit name", + "favourite": "Favourite", + "not_found": "Stop not found" + }, + "map": { + "popup_title": "Stop", + "lines": "Lines" + }, + "common": { + "loading": "Loading...", + "error": "An unexpected error occurred.", + "404": "The requested page could not be found." + } +} diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json new file mode 100644 index 0000000..814019e --- /dev/null +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -0,0 +1,46 @@ +{ + "about": { + "title": "Sobre UrbanoVigo Web", + "description": "Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España.", + "credits": "Créditos", + "github": "Código en GitHub", + "developed_by": "Desarrollado por", + "data_source_prefix": "Datos obtenidos de", + "data_source_middle": "bajo licencia", + "settings": "Ajustes", + "theme": "Modo:", + "theme_light": "Claro", + "theme_dark": "Oscuro", + "table_style": "Estilo de tabla:", + "table_style_regular": "Mostrar por orden", + "table_style_grouped": "Agrupar por línea", + "map_position_mode": "Posición del mapa:", + "map_position_gps": "Posición GPS", + "map_position_last": "Donde lo dejé", + "details_summary": "¿Qué significa esto?", + "details_table": "La tabla de horarios puede mostrarse de dos formas:", + "details_regular": "Las paradas se muestran en el orden en que se visitan. Aplicaciones como Infobus (Vitrasa) usan este estilo.", + "details_grouped": "Las paradas se agrupan por la línea de autobús. Aplicaciones como iTranvias (A Coruña) o Moovit (más o menos) usan este estilo." + }, + "stoplist": { + "search_placeholder": "Buscar parada...", + "favourites": "Favoritas", + "recents": "Recientes" + }, + "estimates": { + "minutes": "min", + "meters": "m", + "edit": "Editar nombre", + "favourite": "Favorito", + "not_found": "No se encontró la parada" + }, + "map": { + "popup_title": "Parada", + "lines": "Líneas" + }, + "common": { + "loading": "Cargando...", + "error": "Ha ocurrido un error inesperado.", + "404": "La página solicitada no se pudo encontrar." + } +} diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json new file mode 100644 index 0000000..3c0c2fc --- /dev/null +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -0,0 +1,46 @@ +{ + "about": { + "title": "Sobre UrbanoVigo Web", + "description": "Aplicación web para atopar paradas e tempos de chegada dos autobuses urbanos de Vigo, España.", + "credits": "Créditos", + "github": "Código en GitHub", + "developed_by": "Desenvolvido por", + "data_source_prefix": "Datos obtidos de", + "data_source_middle": "baixo licenza", + "settings": "Axustes", + "theme": "Modo:", + "theme_light": "Claro", + "theme_dark": "Escuro", + "table_style": "Estilo de táboa:", + "table_style_regular": "Mostrar por orde", + "table_style_grouped": "Agrupar por liña", + "map_position_mode": "Posición do mapa:", + "map_position_gps": "Posición GPS", + "map_position_last": "Onde o deixei", + "details_summary": "Que significa isto?", + "details_table": "A táboa de horarios pode mostrarse de dúas formas:", + "details_regular": "As paradas móstranse na orde na que se visitan. Aplicacións como Infobus (Vitrasa) usan este estilo.", + "details_grouped": "As paradas agrúpanse pola liña de autobús. Aplicacións como iTranvias (A Coruña) ou Moovit (máis ou menos) usan este estilo." + }, + "stoplist": { + "search_placeholder": "Buscar parada...", + "favourites": "Favoritas", + "recents": "Recentes" + }, + "estimates": { + "minutes": "min", + "meters": "m", + "edit": "Editar nome", + "favourite": "Favorita", + "not_found": "Non se atopou a parada" + }, + "map": { + "popup_title": "Parada", + "lines": "Liñas" + }, + "common": { + "loading": "Cargando...", + "error": "Produciuse un erro inesperado.", + "404": "Non se puido atopar a páxina solicitada." + } +} diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css index 89da67a..a5024df 100644 --- a/src/frontend/app/root.css +++ b/src/frontend/app/root.css @@ -45,6 +45,7 @@ body { .main-content { flex: 1; overflow: auto; + overflow-x: hidden; } .navigation-bar { diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx index 9d59ce7..8ffbe86 100644 --- a/src/frontend/app/root.tsx +++ b/src/frontend/app/root.tsx @@ -22,6 +22,8 @@ const pmtiles = new Protocol(); maplibregl.addProtocol("pmtiles", pmtiles.tile); //#endregion +import "./i18n"; + if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js') .then((registration) => { diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx index 761a8d4..5dbbc7d 100644 --- a/src/frontend/app/routes/estimates-$id.tsx +++ b/src/frontend/app/routes/estimates-$id.tsx @@ -6,6 +6,7 @@ import "./estimates-$id.css"; import { RegularTable } from "../components/RegularTable"; import { useApp } from "../AppContext"; import { GroupedTable } from "../components/GroupedTable"; +import { useTranslation } from "react-i18next"; export interface StopDetails { stop: { @@ -32,6 +33,7 @@ const loadData = async (stopId: string) => { }; export default function Estimates() { + const { t } = useTranslation(); const params = useParams(); const stopIdNum = parseInt(params.id ?? ""); const [customName, setCustomName] = useState(undefined); diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index a938148..ca095e2 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -8,6 +8,7 @@ import { loadStyle } from "app/maps/styleloader"; import type { Feature as GeoJsonFeature, Point } from 'geojson'; import LineIcon from "~/components/LineIcon"; import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; // Default minimal fallback style before dynamic loading const defaultStyle: StyleSpecification = { @@ -20,6 +21,7 @@ const defaultStyle: StyleSpecification = { // Componente principal del mapa export default function StopMap() { + const { t } = useTranslation(); const [stops, setStops] = useState[]>([]); const [popupInfo, setPopupInfo] = useState(null); const { mapState, updateMapState, theme } = useApp(); diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx index b5e91f1..e657c03 100644 --- a/src/frontend/app/routes/settings.tsx +++ b/src/frontend/app/routes/settings.tsx @@ -1,64 +1,75 @@ import { useApp } from "../AppContext"; import "./settings.css"; +import { useTranslation } from "react-i18next"; export default function Settings() { + const { t, i18n } = useTranslation(); const { theme, setTheme, tableStyle, setTableStyle, mapPositionMode, setMapPositionMode } = useApp(); return (
-

Sobre UrbanoVigo Web

+

{t('about.title')}

- Aplicación web para encontrar paradas y tiempos de llegada de los autobuses - urbanos de Vigo, España. + {t('about.description')}

-

Ajustes

+

{t('about.settings')}

- - setTheme(e.target.value as "light" | "dark")}> + +
- - setTableStyle(e.target.value as "regular" | "grouped")}> + +
- - setMapPositionMode(e.target.value as 'gps' | 'last')}> + + + +
+
+ +
- ¿Qué significa esto? -

- La tabla de horarios puede mostrarse de dos formas: -

+ {t('about.details_summary')} +

{t('about.details_table')}

-
Mostrar por orden
-
Las paradas se muestran en el orden en que se visitan. Aplicaciones como Infobus (Vitrasa) usan este estilo.
-
Agrupar por línea
-
Las paradas se agrupan por la línea de autobús. Aplicaciones como iTranvias (A Coruña) o Moovit (más o menos) usan este estilo.
+
{t('about.table_style_regular')}
+
{t('about.details_regular')}
+
{t('about.table_style_grouped')}
+
{t('about.details_grouped')}
-

Créditos

+

{t('about.credits')}

- Código en GitHub + {t('about.github')} - - Desarrollado por + {t('about.developed_by')} Ariel Costas

- Datos obtenidos de datos.vigo.org bajo - licencia Open Data Commons Attribution License + {t('about.data_source_prefix')} datos.vigo.org {t('about.data_source_middle')} Open Data Commons Attribution License

) diff --git a/src/frontend/app/routes/stoplist.tsx b/src/frontend/app/routes/stoplist.tsx index ff1da71..0ab6d15 100644 --- a/src/frontend/app/routes/stoplist.tsx +++ b/src/frontend/app/routes/stoplist.tsx @@ -3,25 +3,15 @@ import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import StopItem from "../components/StopItem"; import Fuse from "fuse.js"; import './stoplist.css'; - -const placeholders = [ - "Urzaiz", - "Gran Vía", - "Castelao", - "García Barbón", - "Valladares", - "Florida", - "Pizarro", - "Estrada Madrid", - "Sanjurjo Badía" -]; +import { useTranslation } from "react-i18next"; export default function StopList() { + const { t } = useTranslation(); const [data, setData] = useState(null) const [searchResults, setSearchResults] = useState(null); const searchTimeout = useRef(null); - const randomPlaceholder = useMemo(() => placeholders[Math.floor(Math.random() * placeholders.length)], []); + const randomPlaceholder = useMemo(() => t('stoplist.search_placeholder'), [t]); const fuse = useMemo(() => new Fuse(data || [], { threshold: 0.3, keys: ['name.original'] }), [data]); useEffect(() => { -- cgit v1.3