From 9fd00f54679b81180fb98bb5951d0a00800234a8 Mon Sep 17 00:00:00 2001 From: ffillouxdev Date: Mon, 13 Apr 2026 18:24:49 +0200 Subject: [PATCH 001/122] feat(): add hash router, i18next i18n (en/fr/es), and page views Set up client-side hash routing with i18next for internationalization. Routes: / (identification key), /catalogue, /privacy, /legal. Browser language auto-detection with fallback to English. Language selector in navbar persisted to localStorage. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + package-lock.json | 52 ++++++++++++++- package.json | 4 ++ src/i18n.ts | 144 +++++++++++++++++++++++++++++++++++++++++ src/layout.ts | 29 +++++++++ src/main.ts | 11 +--- src/router.ts | 61 +++++++++++++++++ src/views/catalogue.ts | 14 ++++ src/views/home.ts | 18 ++++++ src/views/legal.ts | 27 ++++++++ src/views/privacy.ts | 27 ++++++++ 11 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 src/i18n.ts create mode 100644 src/layout.ts create mode 100644 src/router.ts create mode 100644 src/views/catalogue.ts create mode 100644 src/views/home.ts create mode 100644 src/views/legal.ts create mode 100644 src/views/privacy.ts diff --git a/.gitignore b/.gitignore index a547bf3..4dc75f0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +CLAUDE.md diff --git a/package-lock.json b/package-lock.json index a294129..059cc1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,11 +7,23 @@ "": { "name": "guide-to-sugarcane-diseases", "version": "0.0.0", + "dependencies": { + "i18next": "^26.0.4", + "i18next-browser-languagedetector": "^8.2.1" + }, "devDependencies": { "typescript": "~5.6.2", "vite": "^5.4.10" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -774,6 +786,44 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/i18next": { + "version": "26.0.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.4.tgz", + "integrity": "sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1145,7 +1195,7 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 3c1ecfa..798c823 100644 --- a/package.json +++ b/package.json @@ -11,5 +11,9 @@ "devDependencies": { "typescript": "~5.6.2", "vite": "^5.4.10" + }, + "dependencies": { + "i18next": "^26.0.4", + "i18next-browser-languagedetector": "^8.2.1" } } diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..3fa2fa7 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,144 @@ +import i18next from 'i18next' +import LanguageDetector from 'i18next-browser-languagedetector' + +const resources = { + en: { + translation: { + nav: { + home: 'Identification Key', + catalogue: 'Catalog', + privacy: 'Privacy', + legal: 'Legal notice', + }, + home: { + title: 'Identification Key', + subtitle: 'A Guide to Sugarcane Diseases', + hint: 'Answer a few questions about the observed symptoms\nto identify the disease.', + cta: 'Start diagnosis', + }, + catalogue: { + title: 'Catalog', + subtitle: 'Complete list of referenced diseases', + empty: 'The catalog will be available once cle.json is loaded.', + }, + privacy: { + title: 'Privacy Policy', + dataHeading: 'Data collected', + dataBody: 'This application collects no personal data. No cookies, no trackers, no analytics.', + offlineHeading: 'Offline use', + offlineBody: 'The application may be cached by your browser (PWA). This data remains exclusively on your device.', + controllerHeading: 'Controller', + controllerBody: 'CIRAD — French agricultural research and cooperation organisation working for the sustainable development of tropical and Mediterranean regions.', + }, + legal: { + title: 'Legal Notice', + publisherHeading: 'Publisher', + publisherBody: 'CIRAD — Centre de coopération internationale en recherche agronomique pour le développement.', + hostingHeading: 'Hosting', + hostingBody: 'Hosted by CIRAD.', + creditsHeading: 'Credits', + creditsBody: 'Based on the book "A Guide to Sugarcane Diseases". Application developed for CIRAD.', + }, + notFound: 'Page not found', + backHome: 'Back to home', + }, + }, + fr: { + translation: { + nav: { + home: "Clé d'identification", + catalogue: 'Catalogue', + privacy: 'Confidentialité', + legal: 'Mentions légales', + }, + home: { + title: "Clé d'identification", + subtitle: 'Guide des maladies de la canne à sucre', + hint: 'Répondez à quelques questions sur les symptômes observés\npour identifier la maladie.', + cta: 'Commencer le diagnostic', + }, + catalogue: { + title: 'Catalogue', + subtitle: 'Liste complète des maladies référencées', + empty: 'Le catalogue sera disponible une fois le fichier cle.json chargé.', + }, + privacy: { + title: 'Politique de confidentialité', + dataHeading: 'Données collectées', + dataBody: "Cette application ne collecte aucune donnée personnelle. Aucun cookie, aucun tracker, aucune analytics.", + offlineHeading: 'Fonctionnement hors connexion', + offlineBody: "L'application peut être mise en cache par votre navigateur (PWA). Ces données restent exclusivement sur votre appareil.", + controllerHeading: 'Responsable', + controllerBody: 'CIRAD — Centre de coopération internationale en recherche agronomique pour le développement.', + }, + legal: { + title: 'Mentions légales', + publisherHeading: 'Éditeur', + publisherBody: 'CIRAD — Centre de coopération internationale en recherche agronomique pour le développement.', + hostingHeading: 'Hébergement', + hostingBody: 'Hébergé par le CIRAD.', + creditsHeading: 'Crédits', + creditsBody: "Basé sur l'ouvrage « A Guide to Sugarcane Diseases ». Application développée pour le CIRAD.", + }, + notFound: 'Page introuvable', + backHome: "Retour à l'accueil", + }, + }, + es: { + translation: { + nav: { + home: 'Clave de identificación', + catalogue: 'Catálogo', + privacy: 'Privacidad', + legal: 'Aviso legal', + }, + home: { + title: 'Clave de identificación', + subtitle: 'Guía de enfermedades de la caña de azúcar', + hint: 'Responda algunas preguntas sobre los síntomas observados\npara identificar la enfermedad.', + cta: 'Iniciar diagnóstico', + }, + catalogue: { + title: 'Catálogo', + subtitle: 'Lista completa de enfermedades referenciadas', + empty: 'El catálogo estará disponible una vez cargado el archivo cle.json.', + }, + privacy: { + title: 'Política de privacidad', + dataHeading: 'Datos recopilados', + dataBody: 'Esta aplicación no recopila datos personales. Sin cookies, sin rastreadores, sin analytics.', + offlineHeading: 'Uso sin conexión', + offlineBody: 'La aplicación puede ser almacenada en caché por su navegador (PWA). Estos datos permanecen exclusivamente en su dispositivo.', + controllerHeading: 'Responsable', + controllerBody: 'CIRAD — Centro de cooperación internacional en investigación agronómica para el desarrollo.', + }, + legal: { + title: 'Aviso legal', + publisherHeading: 'Editor', + publisherBody: 'CIRAD — Centro de cooperación internacional en investigación agronómica para el desarrollo.', + hostingHeading: 'Alojamiento', + hostingBody: 'Alojado por CIRAD.', + creditsHeading: 'Créditos', + creditsBody: 'Basado en el libro "A Guide to Sugarcane Diseases". Aplicación desarrollada para CIRAD.', + }, + notFound: 'Página no encontrada', + backHome: 'Volver al inicio', + }, + }, +} + +await i18next + .use(LanguageDetector) + .init({ + resources, + fallbackLng: 'en', + supportedLngs: ['en', 'fr', 'es'], + detection: { + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + lookupLocalStorage: 'dcas-lang', + }, + interpolation: { escapeValue: false }, + }) + +export default i18next diff --git a/src/layout.ts b/src/layout.ts new file mode 100644 index 0000000..eb446e3 --- /dev/null +++ b/src/layout.ts @@ -0,0 +1,29 @@ +import i18next from './i18n' + +const languages = [ + { code: 'en', label: 'English' }, + { code: 'fr', label: 'Français' }, + { code: 'es', label: 'Español' }, +] + +export function nav(): string { + const t = i18next.t.bind(i18next) + const current = i18next.language + + const options = languages.map(({ code, label }) => + `` + ).join('') + + return /*html*/` + + ` +} diff --git a/src/main.ts b/src/main.ts index 10be2f3..d6e6f64 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,5 @@ import './style.css' +import { initRouter } from './router' -document.querySelector('#app')!.innerHTML = /*html*/ ` -
-

- Guide to Sugarcane Diseases -

-
-` - +const app = document.querySelector('#app')! +initRouter(app) diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..eb5e96d --- /dev/null +++ b/src/router.ts @@ -0,0 +1,61 @@ +import i18next from './i18n' +import { nav } from './layout' +import { homeView } from './views/home' +import { catalogueView } from './views/catalogue' +import { privacyView } from './views/privacy' +import { legalView } from './views/legal' + +type Route = { + paths: string[] + view: () => string + titleKey: string +} + +const routes: Route[] = [ + { paths: ['/', ''], view: homeView, titleKey: 'home.title' }, + { paths: ['/catalogue', '/catalog'], view: catalogueView, titleKey: 'catalogue.title' }, + { paths: ['/privacy'], view: privacyView, titleKey: 'privacy.title' }, + { paths: ['/legal'], view: legalView, titleKey: 'legal.title' }, +] + +function getHash(): string { + return window.location.hash.slice(1) || '/' +} + +function notFoundView(): string { + const t = i18next.t.bind(i18next) + return /*html*/` + ${nav()} +
+

404

+

${t('notFound')}

+ ${t('backHome')} +
+ ` +} + +function matchRoute(path: string): { html: string; titleKey: string } { + const route = routes.find(r => r.paths.includes(path)) + if (route) { + return { html: route.view(), titleKey: route.titleKey } + } + return { html: notFoundView(), titleKey: 'notFound' } +} + +function render(app: HTMLElement): void { + const path = getHash() + const { html, titleKey } = matchRoute(path) + app.innerHTML = html + document.title = `${i18next.t(titleKey)} — D-CAS 2.0` + document.documentElement.lang = i18next.language + + const select = document.getElementById('lang-select') as HTMLSelectElement | null + select?.addEventListener('change', () => { + i18next.changeLanguage(select.value).then(() => render(app)) + }) +} + +export function initRouter(app: HTMLElement): void { + window.addEventListener('hashchange', () => render(app)) + render(app) +} diff --git a/src/views/catalogue.ts b/src/views/catalogue.ts new file mode 100644 index 0000000..62d2f48 --- /dev/null +++ b/src/views/catalogue.ts @@ -0,0 +1,14 @@ +import i18next from '../i18n' +import { nav } from '../layout' + +export function catalogueView(): string { + const t = i18next.t.bind(i18next) + return /*html*/` + ${nav()} +
+

${t('catalogue.title')}

+

${t('catalogue.subtitle')}

+

${t('catalogue.empty')}

+
+ ` +} diff --git a/src/views/home.ts b/src/views/home.ts new file mode 100644 index 0000000..b62a4cc --- /dev/null +++ b/src/views/home.ts @@ -0,0 +1,18 @@ +import i18next from '../i18n' +import { nav } from '../layout' + +export function homeView(): string { + const t = i18next.t.bind(i18next) + return /*html*/` + ${nav()} +
+

CIRAD · D-CAS 2.0

+

${t('home.title')}

+

${t('home.subtitle')}

+

${t('home.hint')}

+ +
+ ` +} diff --git a/src/views/legal.ts b/src/views/legal.ts new file mode 100644 index 0000000..1bb69f8 --- /dev/null +++ b/src/views/legal.ts @@ -0,0 +1,27 @@ +import i18next from '../i18n' +import { nav } from '../layout' + +export function legalView(): string { + const t = i18next.t.bind(i18next) + + const sections: Array<{ heading: string; body: string }> = [ + { heading: t('legal.publisherHeading'), body: t('legal.publisherBody') }, + { heading: t('legal.hostingHeading'), body: t('legal.hostingBody') }, + { heading: t('legal.creditsHeading'), body: t('legal.creditsBody') }, + ] + + const sectionsHtml = sections.map(({ heading, body }) => /*html*/` +
+

${heading}

+

${body}

+
+ `).join('') + + return /*html*/` + ${nav()} +
+

${t('legal.title')}

+ ${sectionsHtml} +
+ ` +} diff --git a/src/views/privacy.ts b/src/views/privacy.ts new file mode 100644 index 0000000..5500d91 --- /dev/null +++ b/src/views/privacy.ts @@ -0,0 +1,27 @@ +import i18next from '../i18n' +import { nav } from '../layout' + +export function privacyView(): string { + const t = i18next.t.bind(i18next) + + const sections: Array<{ heading: string; body: string }> = [ + { heading: t('privacy.dataHeading'), body: t('privacy.dataBody') }, + { heading: t('privacy.offlineHeading'), body: t('privacy.offlineBody') }, + { heading: t('privacy.controllerHeading'), body: t('privacy.controllerBody') }, + ] + + const sectionsHtml = sections.map(({ heading, body }) => /*html*/` +
+

${heading}

+

${body}

+
+ `).join('') + + return /*html*/` + ${nav()} +
+

${t('privacy.title')}

+ ${sectionsHtml} +
+ ` +} From 43609b3e046cd0ea375de8091c8b37cad2073c36 Mon Sep 17 00:00:00 2001 From: ffillouxdev Date: Mon, 13 Apr 2026 18:49:50 +0200 Subject: [PATCH 002/122] fix(): replace hash router with History API and add i18n localized URLs Co-Authored-By: Claude Opus 4.6 --- src/layout.ts | 18 +++++++++--- src/router.ts | 79 ++++++++++++++++++++++++++++---------------------- vite.config.ts | 5 ++++ 3 files changed, 63 insertions(+), 39 deletions(-) create mode 100644 vite.config.ts diff --git a/src/layout.ts b/src/layout.ts index eb446e3..03f32e7 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -6,6 +6,16 @@ const languages = [ { code: 'es', label: 'Español' }, ] +const localizedPaths: Record> = { + catalogue: { en: '/catalogue', fr: '/catalogue', es: '/catalogo' }, + privacy: { en: '/privacy', fr: '/confidentialite', es: '/privacidad' }, + legal: { en: '/legal', fr: '/mentions-legales', es: '/aviso-legal' }, +} + +function lp(key: string): string { + return localizedPaths[key]?.[i18next.language] ?? localizedPaths[key]?.en ?? '/' +} + export function nav(): string { const t = i18next.t.bind(i18next) const current = i18next.language @@ -16,11 +26,11 @@ export function nav(): string { return /*html*/`