diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..80690af
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,27 @@
+version: 2
+updates:
+ # npm dependencies
+ - package-ecosystem: npm
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ open-pull-requests-limit: 5
+ labels:
+ - dependencies
+ commit-message:
+ prefix: chore(deps)
+ include: scope
+
+ # GitHub Actions workflow updates
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ open-pull-requests-limit: 3
+ labels:
+ - dependencies
+ - ci
+ commit-message:
+ prefix: chore(ci)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..3d02bb1
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,47 @@
+name: CI
+
+on:
+ push:
+ branches: [main, claude]
+ pull_request:
+ branches: [main]
+
+jobs:
+ build:
+ name: Type-check & build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build (tsc + vite)
+ run: npm run build
+
+ - name: Verify build artefacts
+ run: npm run verify
+
+ audit:
+ name: npm audit (critical only)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Audit
+ run: npm audit --audit-level=critical
diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml
new file mode 100644
index 0000000..1f70874
--- /dev/null
+++ b/.github/workflows/sonar.yml
@@ -0,0 +1,32 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main, claude]
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+jobs:
+ sonar:
+ name: Analyze
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - run: npm ci
+
+ - uses: SonarSource/sonarcloud-github-action@master
+ continue-on-error: true
+ with:
+ args: -Dsonar.qualitygate.wait=false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
diff --git a/.gitignore b/.gitignore
index a547bf3..91ef82f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,24 +1,15 @@
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-lerna-debug.log*
-
+# Dependencies
node_modules
+package-lock.json
+
+# Build output
dist
-dist-ssr
-*.local
-# Editor directories and files
-.vscode/*
-!.vscode/extensions.json
+# Confidential assets
+public/datas/
+
+# IDE
+.vscode
.idea
-.DS_Store
-*.suo
-*.ntvs*
-*.njsproj
-*.sln
-*.sw?
+*.swp
+CLAUDE.md
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..a65c11a
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,203 @@
+# Architecture Overview
+
+D-CAS 2.0 is a client-side, framework-free single-page application built with TypeScript, Vite, and Tailwind CSS. This document describes how the system is organized and how its key features work.
+
+## Directory Structure
+
+```
+src/
+├── main.ts # Entry point: renders header into #header-mount,
+│ # initializes offline support & router
+├── router.ts # Client-side router & route definitions
+├── layout.ts # Re-exports header / bindHeaderEvents / initOffline
+├── style.css # Tailwind directives
+├── i18n.ts # Translation resources (EN/FR/ES) & language detection
+│
+├── views/ # Page-level components (return HTML strings — no header)
+│ ├── home.ts # Interactive questionnaire
+│ ├── catalog.ts # Disease list with search & sorting
+│ ├── about.ts # About page (credits, CIRAD info)
+│ ├── privacy.ts # Privacy policy
+│ └── legal.ts # Legal notice & copyright
+│
+├── components/ # Reusable UI pieces (return HTML strings)
+│ ├── header.ts # Navigation, language selector, offline toggle
+│ ├── questionnaire.ts # Question tree navigation & disease results
+│ ├── disease_result.ts # Disease detail view with carousel
+│ ├── call_to_action.ts # CTA for sugarcane diseases book
+│ ├── cirad_corner.ts # CIRAD corner logo (home page)
+│ ├── breadcrumb.ts # Navigation breadcrumb
+│ └── question_button.ts # Styled question button
+│
+├── data/
+│ └── key-loader.ts # Loads & caches disease JSON from public/datas/
+│
+└── vite-env.d.ts # Vite type definitions
+```
+
+`index.html` has two mount points: `#header-mount` (header rendered once at boot, re-rendered only on language change) and `#app` (route content, swapped on navigation).
+
+## Routing
+
+**Client-side router** in `router.ts` with three-language URL aliases. Each route may declare an optional `init` callback for post-render wiring:
+
+```ts
+type Route = {
+ path: string
+ titleKey: string
+ view: () => string
+ init?: () => void
+}
+
+const routes: Route[] = [
+ { path: '/', titleKey: 'home.title', view: homeView, init: initQuestionnaire },
+ { path: '/catalogue', titleKey: 'catalogue.title', view: catalogueView, init: initCatalogue },
+ { path: '/catalog', titleKey: 'catalogue.title', view: catalogueView, init: initCatalogue },
+ { path: '/catalogo', titleKey: 'catalogue.title', view: catalogueView, init: initCatalogue },
+ // ... etc for about, privacy, legal
+]
+```
+
+**Navigation flow:**
+1. User clicks an internal `` tag.
+2. Router intercepts the click (skipping `target="_blank"`, `download`, modifier keys, `mailto:`, `tel:` and absolute URLs), prevents default, calls `navigateTo()`.
+3. `navigateTo()` pushes state to history and re-renders the main content (`app.innerHTML = route.view()`).
+4. `route.init?.()` runs after the view is in the DOM.
+5. `window.popstate` listener handles back/forward buttons.
+
+The header is **not** part of `route.view()` — it lives in its own `#header-mount` and is only re-rendered when the language changes.
+
+## Internationalization (i18n)
+
+**Language detection** in `i18n.ts`:
+1. Browser's language detector (`i18next-browser-languagedetector`) reads `navigator.language`.
+2. Falls back to English if user's language is not supported.
+
+**Translation resources:**
+- Single object with three languages (EN, FR, ES).
+- Nested keys: `t('catalogue.diseaseNameColumn')`.
+- All visible text must be added here — never hardcoded in views.
+
+**Changing language:**
+- User selects from dropdown in header.
+- `i18next.changeLanguage()` updates the language.
+- `main.ts`'s `refreshHeader()` re-renders the header into `#header-mount`, then `router.render()` re-renders the current route's main content.
+- Service Worker is notified to precache the selected language's image assets.
+
+**URL paths are localized:**
+```
+/catalogue (English)
+/catalogo (Spanish)
+/catalogue (French, same as English by convention)
+```
+
+The header component (`header.ts`) maps these paths based on current language via `lp(key)` helper.
+
+## Data Loading & Caching
+
+**Disease data** is loaded from JSON files in `public/datas/`:
+
+```
+identification-key.json (English)
+cle-identification.json (French)
+clave-de-identificacion.json (Spanish)
+diseases-img/ (Disease photographs)
+```
+
+**Key loader** (`data/key-loader.ts`):
+- Single fetch per session (cached in memory).
+- If a translated key file is empty, falls back to English.
+- Used by both questionnaire and catalog views.
+
+## Offline Support (Service Worker)
+
+**Service Worker** in `public/sw.js`:
+
+1. **App shell** (HTML, identification keys, favicon) — precached at install time.
+2. **Vite bundles** (hashed JS/CSS) — at install time, the SW fetches `/.vite/manifest.json` (generated by `build.manifest: true` in `vite.config.ts`) and precaches every entry's `file`, `css`, and `assets`. This guarantees the app works fully offline on first visit.
+3. **Disease images** — precached on demand when the user picks a language (or after initial SW registration). Workers run in parallel (6 concurrent fetches) with an in-progress guard per language so rapid language toggles don't queue duplicate runs.
+4. **Runtime strategy** — images (`/datas/diseases-img/*` and `/assets/*.{png,jpg,jpeg,webp,svg,gif,ico}`) use cache-first; everything else uses network-first with cache fallback.
+5. **Cache busting** — bump `CACHE_NAME` in `sw.js` when the caching strategy changes. Old caches are deleted on activation.
+
+**Image precaching progress:**
+- SW broadcasts `{ type: 'precache-progress', done, total }` to all clients every 10 images.
+- Header dropdown shows the progress bar and "Downloading 45/120" / "Ready for offline use ✓".
+
+**User can toggle offline mode** via header switch (`offline-toggle`). This registers or unregisters the SW and wipes its caches.
+
+## Component Patterns
+
+All views and components follow the same pattern:
+
+```ts
+export function myComponent(): string {
+ const t = i18next.t.bind(i18next)
+
+ return /*html*/`
+
+ ${t('my.key')}
+
+ `
+}
+
+export function initMyComponent(): void {
+ // Set up event listeners
+ document.querySelector('.my-class')?.addEventListener('click', ...)
+}
+```
+
+**Why this pattern?**
+- No JSX, no virtual DOM, no build complexity.
+- Easy to modify styling (Tailwind utilities in the HTML template).
+- Event listeners bound explicitly (no magic).
+- Type-safe (TypeScript strict mode).
+
+## Adding a New Page
+
+1. **Create view** in `src/views/mypage.ts`:
+ ```ts
+ export function myPageView(): string { return `...` }
+ export function initMyPage(): void { /* listeners */ }
+ ```
+
+2. **Add translations** to `src/i18n.ts` (EN, FR, ES).
+
+3. **Register route** in `src/router.ts` (use the `init?` field to wire post-render listeners):
+ ```ts
+ { path: '/mypage', titleKey: 'mypage.title', view: myPageView, init: initMyPage }
+ ```
+
+4. **Add navigation link** in `src/components/header.ts` (if needed).
+
+## Key Technical Decisions
+
+| Decision | Rationale |
+| -------- | --------- |
+| No framework | Minimal bundle, easy to modify, direct DOM control |
+| TypeScript strict | Catch errors at build time, no `any` escapes |
+| Tailwind CSS | Responsive design, utility-first, zero unused CSS |
+| Service Worker | Full offline support for field use |
+| i18next | Standard i18n library, easy to add languages |
+| String HTML templates | No build step for views, fast iteration |
+| Client-side router | No backend needed, static hosting compatible |
+
+## Performance Considerations
+
+- **Initial load** — ~200KB gzipped (Vite optimizes JS, Tailwind purges unused classes).
+- **Disease images** — Downloaded on-demand by Service Worker, cached for offline use.
+- **Catalog search** — Linear scan of disease list (fast enough for <200 diseases).
+- **Mobile landscape** — Special height handling (`max-md:landscape:h-[200vh]`) to prevent table clipping.
+
+## Confidential Assets
+
+`public/datas/` is **gitignored** and must be provided by CIRAD. Without it:
+- Questionnaire loads empty data (shows "Downloading...").
+- Catalog shows empty list.
+- Images are not available.
+
+The app degrades gracefully; it doesn't crash.
+
+---
+
+**Last updated:** May 2026
+**Maintained by:** [ffillouxdev](https://github.com/ffillouxdev)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fb48d6c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,101 @@
+# D-CAS 2.0 — A Guide to Sugarcane Diseases
+
+> Offline-first Progressive Web App for identifying sugarcane diseases, built for [CIRAD](https://www.cirad.fr/).
+
+D-CAS 2.0 (*Détermination et Catalogue des Affections de la canne à Sucre*) is a trilingual (EN / FR / ES) field tool that helps agronomists and growers identify sugarcane diseases through a guided questionnaire or a searchable catalog of referenced pathologies.
+
+## Features
+
+- **Guided diagnosis** — Answer a few questions about observed symptoms to narrow down possible diseases.
+- **Disease catalog** — Browse, search and sort all referenced diseases with their pathogens, symptoms, images and geographical distribution.
+- **Fully offline** — Once loaded, the app works without connectivity thanks to a Service Worker that caches the identification key and disease images.
+- **Trilingual** — Interface and content available in English, French and Spanish, with per-language disease trees.
+- **Mobile-friendly** — Responsive layout optimized for both portrait and landscape phone usage in the field.
+
+## Tech Stack
+
+| Concern | Choice |
+| -------------- | --------------------------------------------------------- |
+| Language | TypeScript (strict mode) |
+| Bundler | [Vite 5](https://vitejs.dev/) |
+| Styling | [Tailwind CSS 4](https://tailwindcss.com/) + PostCSS |
+| i18n | [i18next](https://www.i18next.com/) + browser detector |
+| Routing | Custom client-side router (`src/router.ts`) |
+| Offline | Native Service Worker (`public/sw.js`) |
+| Framework | None — vanilla DOM manipulation |
+
+## Getting Started
+
+### Prerequisites
+
+- **Node.js** ≥ 18
+- **npm** ≥ 9
+
+### Installation
+
+```bash
+git clone git@github.com:ffillouxdev/D-CAS-2.0.git
+cd D-CAS-2.0
+npm install
+```
+
+### Development
+
+```bash
+npm run dev
+```
+
+The dev server starts on [http://localhost:5173](http://localhost:5173).
+
+### Production build
+
+```bash
+npm run build # Type-check with tsc, then bundle to dist/
+npm run preview # Preview the production build locally
+```
+
+## Project Structure
+
+```
+D-CAS-2.0/
+├── public/
+│ ├── assets/ # Static images (logos, backgrounds, CTA)
+│ ├── datas/ # Identification keys + disease images (gitignored)
+│ └── sw.js # Service Worker
+├── src/
+│ ├── components/ # Reusable UI pieces (header, questionnaire, ...)
+│ ├── data/ # Data loaders (key-loader.ts)
+│ ├── views/ # Page-level views (home, catalog, about, privacy, legal)
+│ ├── i18n.ts # Translation resources & language detection
+│ ├── layout.ts # Shared layout exports
+│ ├── router.ts # Client-side router
+│ ├── main.ts # Entry point
+│ └── style.css # Tailwind directives
+├── index.html
+├── tailwind.config.js
+├── vite.config.ts
+└── tsconfig.json
+```
+
+## Confidential Assets
+
+The `public/datas/` folder is **gitignored** and must be provided separately by CIRAD. It contains:
+
+- `identification-key.json` — English disease tree (primary source)
+- `cle-identification.json` — French disease tree
+- `clave-de-identificacion.json` — Spanish disease tree
+- `diseases-img/` — Disease photographs (confidential, CIRAD property)
+
+The loader (`src/data/key-loader.ts`) falls back to the English key when a translated key is empty.
+
+## Credits
+
+Based on the book *A Guide to Sugarcane Diseases*, edited by Philippe Rott, Jean-Claude Girard and Jean Heinrich Daugrois, published by [Éditions Quæ](https://www.quae.com/produit/78/9782876143869/a-guide-to-sugarcane-diseases).
+
+## License
+
+Proprietary — © CIRAD. All rights reserved. Disease content and images are the property of CIRAD and contributing photographers.
+
+## Author
+
+Developed by **[ffillouxdev](https://github.com/ffillouxdev)** for CIRAD.
diff --git a/diseases-without-photos.txt b/diseases-without-photos.txt
new file mode 100644
index 0000000..c1b1165
--- /dev/null
+++ b/diseases-without-photos.txt
@@ -0,0 +1,76 @@
+Diseases and disorders without photographs
+==================================================
+
+DISEASES
+------------------------------
+- Alternaria leaf spot
+- Apiospora mold
+- Arrow rot
+- Australian basal stem
+- Bacterial sun spot
+- Baker’s leaf spot
+- Basal stem, root and sheath rot(see also Australian basal stem, root and sheath rot; Marasmius basal stem, root and sheath rot; root and basal stem rot)
+- Basal stem rot
+- Black spot
+- Black stem rot
+- Black stripe
+- Brown rot
+- Charcoal rot
+- Collar rot
+- Covered smut
+- Culm and midrib rot
+- Culmicolous smut (smut)
+- Diplodia rot
+- Dry rot
+- Ergot
+- False floral smut
+- Fusarium root rot
+- Helminthosporium leaf spot(s)
+- Inflorescence binding
+- Leaf binding (= Myriogenospora leaf binding)
+- Leaf blast
+- Leaf sheath binding (Iliau)
+- Leaf sooty mould (sooty mould)
+- Leaf spots
+- Marasmius basal stem
+- Marasmius sheath rot and shoot blight
+- Marasmius sheath rot and shoot blight; root rot
+- Midrib blotch
+- Periconia leaf spot
+- Pestalotia leaf spot
+- Powdery mildew
+- Pseudo-Fiji (leaf galls)
+- Purple spot (red leaf spot)
+- Ramu leaf scorch
+- Red line
+- Red rot of the leaf sheath (now Sclerotium sheath rot)
+- Rhizoctonia sheath and shoot rot
+- Ring mosaic
+- root and sheath rot
+- root rot
+- Root rots (see also Pachymetra root rot; Pythium root rot; Marasmius sheath rot and shoot blight, root rot)
+- Rust (see brown rust, orange rust, and tawny rust)
+- Schizophyllum rot
+- Seedling foliage blights
+- Sett and stem rot
+- Smuts (see covered smut, culmicolous smut, floral smut, false floral smut)
+- Spike
+- Stellate crystal fungus
+- Stinking rot
+- Tar spot (black leaf spot)
+- Target blotch
+- Top rot (red stripe)
+- White leaf
+- White leaf/Grassy shoot
+- White rash (white speck)
+- White streak
+- Zonate foot rot
+
+OTHER CAUSES
+------------------------------
+- Manganese deficiency
+- Nitrogen or sulphur deficiency
+- Potassium deficiency
+- Irrigation problem
+
+Total: 66 entries
\ No newline at end of file
diff --git a/index.html b/index.html
index f3d61df..2b9bbb6 100644
--- a/index.html
+++ b/index.html
@@ -2,14 +2,13 @@
-
+
-
Guide to Sugarcane Diseases
-
+
diff --git a/package-lock.json b/package-lock.json
index a294129..dd92f6c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,11 +7,38 @@
"": {
"name": "guide-to-sugarcane-diseases",
"version": "0.0.0",
+ "dependencies": {
+ "i18next": "^26.0.4",
+ "i18next-browser-languagedetector": "^8.2.1"
+ },
"devDependencies": {
+ "autoprefixer": "^10.5.0",
+ "postcss": "^8.5.10",
+ "tailwindcss": "^3.4.1",
"typescript": "~5.6.2",
"vite": "^5.4.10"
}
},
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "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",
@@ -380,6 +407,76 @@
"node": ">=12"
}
},
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
@@ -711,17 +808,249 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
- "optional": true,
- "peer": true,
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
+ "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "browserslist": "^4.28.2",
+ "caniuse-lite": "^1.0.30001787",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.19",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz",
+ "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==",
+ "dev": true,
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
"engines": {
"node": ">=8"
}
},
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001788",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
+ "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ]
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.340",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz",
+ "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==",
+ "dev": true
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -760,280 +1089,277 @@
"@esbuild/win32-x64": "0.21.5"
}
},
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
- "hasInstallScript": true,
- "optional": true,
- "os": [
- "darwin"
- ],
"engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ "node": ">=6"
}
},
- "node_modules/lightningcss": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
- "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
- "optional": true,
- "peer": true,
"dependencies": {
- "detect-libc": "^2.0.3"
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
},
"engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "lightningcss-android-arm64": "1.32.0",
- "lightningcss-darwin-arm64": "1.32.0",
- "lightningcss-darwin-x64": "1.32.0",
- "lightningcss-freebsd-x64": "1.32.0",
- "lightningcss-linux-arm-gnueabihf": "1.32.0",
- "lightningcss-linux-arm64-gnu": "1.32.0",
- "lightningcss-linux-arm64-musl": "1.32.0",
- "lightningcss-linux-x64-gnu": "1.32.0",
- "lightningcss-linux-x64-musl": "1.32.0",
- "lightningcss-win32-arm64-msvc": "1.32.0",
- "lightningcss-win32-x64-msvc": "1.32.0"
- }
- },
- "node_modules/lightningcss-android-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
- "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
- "cpu": [
- "arm64"
- ],
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
- "optional": true,
- "os": [
- "android"
- ],
- "peer": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
"engines": {
- "node": ">= 12.0.0"
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
},
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "engines": {
+ "node": ">=8"
}
},
- "node_modules/lightningcss-darwin-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
- "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"dev": true,
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
"engines": {
- "node": ">= 12.0.0"
+ "node": "*"
},
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
}
},
- "node_modules/lightningcss-darwin-x64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
- "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
- "cpu": [
- "x64"
- ],
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
+ "hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
- "peer": true,
"engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
- "node_modules/lightningcss-freebsd-x64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
- "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
- "cpu": [
- "x64"
- ],
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
- "optional": true,
- "os": [
- "freebsd"
- ],
- "peer": true,
- "engines": {
- "node": ">= 12.0.0"
- },
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
- "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
- "cpu": [
- "arm"
- ],
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">= 12.0.0"
+ "dependencies": {
+ "is-glob": "^4.0.3"
},
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "engines": {
+ "node": ">=10.13.0"
}
},
- "node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
- "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": ">= 12.0.0"
+ "dependencies": {
+ "function-bind": "^1.1.2"
},
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "engines": {
+ "node": ">= 0.4"
}
},
- "node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
- "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
+ "node_modules/i18next": {
+ "version": "26.0.5",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.5.tgz",
+ "integrity": "sha512-9uHb4T27TdV36phJXcbpnRPt5yzAfqHXVrdASvmHZyPuZJtrLythd+GyXhiaHV5LlpuuskbAqhwPjmfTbKbi8w==",
+ "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"
+ }
],
- "peer": true,
- "engines": {
- "node": ">= 12.0.0"
+ "dependencies": {
+ "@babel/runtime": "^7.29.2"
},
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "peerDependencies": {
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
}
},
- "node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
- "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
- "cpu": [
- "x64"
- ],
+ "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/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
"engines": {
- "node": ">= 12.0.0"
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
},
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/lightningcss-linux-x64-musl": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
- "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
- "cpu": [
- "x64"
- ],
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
"engines": {
- "node": ">= 12.0.0"
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
},
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "engines": {
+ "node": ">=0.10.0"
}
},
- "node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
- "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
"engines": {
- "node": ">= 12.0.0"
+ "node": ">=14"
},
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "url": "https://github.com/sponsors/antonk52"
}
},
- "node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
- "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
- "cpu": [
- "x64"
- ],
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
"engines": {
- "node": ">= 12.0.0"
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
},
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
}
},
"node_modules/nanoid": {
@@ -1054,16 +1380,85 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/node-releases": {
+ "version": "2.0.37",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
+ "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
+ "dev": true
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true
},
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/postcss": {
- "version": "8.5.9",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
- "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
@@ -1088,6 +1483,206 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@@ -1132,6 +1727,29 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1141,11 +1759,166 @@
"node": ">=0.10.0"
}
},
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true
+ },
"node_modules/typescript": {
"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"
@@ -1154,6 +1927,42 @@
"node": ">=14.17"
}
},
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true
+ },
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
diff --git a/package.json b/package.json
index 3c1ecfa..ab8e364 100644
--- a/package.json
+++ b/package.json
@@ -6,10 +6,18 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "verify": "node scripts/verify-build.js"
},
"devDependencies": {
+ "autoprefixer": "^10.5.0",
+ "postcss": "^8.5.10",
+ "tailwindcss": "^3.4.1",
"typescript": "~5.6.2",
"vite": "^5.4.10"
+ },
+ "dependencies": {
+ "i18next": "^26.0.4",
+ "i18next-browser-languagedetector": "^8.2.1"
}
}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/public/assets/a_guide_to_sugarcane-img.png b/public/assets/a_guide_to_sugarcane-img.png
new file mode 100644
index 0000000..ba5a8a1
Binary files /dev/null and b/public/assets/a_guide_to_sugarcane-img.png differ
diff --git a/public/assets/docs/maquettes_D-CAS_v1.1.0.pdf b/public/assets/docs/maquettes_D-CAS_v1.1.0.pdf
new file mode 100644
index 0000000..c2ffed3
Binary files /dev/null and b/public/assets/docs/maquettes_D-CAS_v1.1.0.pdf differ
diff --git a/public/assets/favicon.ico b/public/assets/favicon.ico
new file mode 100644
index 0000000..37da5b1
Binary files /dev/null and b/public/assets/favicon.ico differ
diff --git a/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBold.otf b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBold.otf
new file mode 100644
index 0000000..95eac52
Binary files /dev/null and b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBold.otf differ
diff --git a/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBoldItalic.otf b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBoldItalic.otf
new file mode 100644
index 0000000..75e19fe
Binary files /dev/null and b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBoldItalic.otf differ
diff --git a/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProItalic.otf b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProItalic.otf
new file mode 100644
index 0000000..0a3346e
Binary files /dev/null and b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProItalic.otf differ
diff --git a/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProRoman.otf b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProRoman.otf
new file mode 100644
index 0000000..0640d7e
Binary files /dev/null and b/public/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProRoman.otf differ
diff --git a/public/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-BOLD.OTF b/public/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-BOLD.OTF
new file mode 100644
index 0000000..ebf00fa
Binary files /dev/null and b/public/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-BOLD.OTF differ
diff --git a/public/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-REGULAR.OTF b/public/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-REGULAR.OTF
new file mode 100644
index 0000000..e7b7f26
Binary files /dev/null and b/public/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-REGULAR.OTF differ
diff --git a/public/assets/logo-cirad.svg b/public/assets/logo-cirad.svg
new file mode 100644
index 0000000..e2e9728
--- /dev/null
+++ b/public/assets/logo-cirad.svg
@@ -0,0 +1,829 @@
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/main-bg.png b/public/assets/main-bg.png
new file mode 100644
index 0000000..98cf258
Binary files /dev/null and b/public/assets/main-bg.png differ
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..20b2b66
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,173 @@
+const CACHE_NAME = 'dcas-v5'
+
+const APP_SHELL = [
+ '/',
+ '/index.html',
+ '/assets/favicon.ico',
+ '/datas/identification-key.json',
+ '/datas/cle-identification.json',
+ '/datas/clave-de-identificacion.json',
+]
+
+const LANG_FILES = {
+ en: '/datas/identification-key.json',
+ fr: '/datas/cle-identification.json',
+ es: '/datas/clave-de-identificacion.json',
+}
+
+// Reads Vite's build manifest at install time and adds the hashed JS/CSS
+// bundles to the precache. Falls back gracefully in dev (no manifest).
+async function getViteBundleAssets() {
+ try {
+ const res = await fetch('/.vite/manifest.json', { cache: 'no-cache' })
+ if (!res.ok) return []
+ const manifest = await res.json()
+ const urls = new Set()
+ for (const entry of Object.values(manifest)) {
+ if (entry.file) urls.add('/' + entry.file)
+ if (Array.isArray(entry.css)) entry.css.forEach((f) => urls.add('/' + f))
+ if (Array.isArray(entry.assets)) entry.assets.forEach((f) => urls.add('/' + f))
+ }
+ return [...urls]
+ } catch {
+ return []
+ }
+}
+
+globalThis.addEventListener('install', (event) => {
+ event.waitUntil((async () => {
+ const cache = await caches.open(CACHE_NAME)
+ const bundles = await getViteBundleAssets()
+ const all = [...new Set([...APP_SHELL, ...bundles])]
+ await Promise.all(all.map((url) => cache.add(url).catch(() => {})))
+ })())
+ globalThis.skipWaiting()
+})
+
+globalThis.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then((keys) =>
+ Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
+ )
+ )
+ globalThis.clients.claim()
+})
+
+function isImageRequest(request) {
+ const url = new URL(request.url)
+ return url.pathname.startsWith('/datas/diseases-img/') ||
+ (url.pathname.startsWith('/assets/') && /\.(png|jpg|jpeg|webp|svg|gif|ico)$/i.test(url.pathname))
+}
+
+async function cacheFirst(request) {
+ const cached = await caches.match(request)
+ if (cached) return cached
+ try {
+ const response = await fetch(request)
+ if (response.ok) {
+ const cache = await caches.open(CACHE_NAME)
+ cache.put(request, response.clone())
+ }
+ return response
+ } catch {
+ return new Response('Not found', { status: 404 })
+ }
+}
+
+async function networkFirst(request) {
+ try {
+ const response = await fetch(request)
+ if (response.ok) {
+ const cache = await caches.open(CACHE_NAME)
+ cache.put(request, response.clone())
+ }
+ return response
+ } catch {
+ const cached = await caches.match(request)
+ if (cached) return cached
+ if (request.mode === 'navigate') {
+ const fallback = (await caches.match('/index.html')) || (await caches.match('/'))
+ if (fallback) return fallback
+ }
+ return new Response('Not found', { status: 404 })
+ }
+}
+
+globalThis.addEventListener('fetch', (event) => {
+ const { request } = event
+ if (request.method !== 'GET') return
+ if (!request.url.startsWith(globalThis.location.origin)) return
+
+ event.respondWith(isImageRequest(request) ? cacheFirst(request) : networkFirst(request))
+})
+
+globalThis.addEventListener('message', (event) => {
+ if (!event.source) return
+ if (event.data?.type === 'precache-images') {
+ event.waitUntil(precacheImagesForLang(event.data.lang || 'en'))
+ }
+})
+
+async function fetchLangData(lang) {
+ const path = LANG_FILES[lang] || LANG_FILES.en
+ try {
+ const res = await fetch(path)
+ if (!res.ok) return null
+ const data = await res.json()
+ if (!data?.nodes) return null
+ return data
+ } catch {
+ return null
+ }
+}
+
+async function broadcast(msg) {
+ const clients = await globalThis.clients.matchAll({ includeUncontrolled: true })
+ clients.forEach((c) => c.postMessage(msg))
+}
+
+const precachingLangs = new Set()
+const PRECACHE_CONCURRENCY = 6
+
+async function precacheImagesForLang(lang) {
+ if (precachingLangs.has(lang)) return
+ precachingLangs.add(lang)
+ try {
+ let data = await fetchLangData(lang)
+ if (!data && lang !== 'en') data = await fetchLangData('en')
+ if (!data) return
+
+ const urls = new Set()
+ const collect = (obj) => {
+ if (obj?.image && Array.isArray(obj.image)) {
+ obj.image.forEach((u) => urls.add(u))
+ }
+ }
+ Object.values(data.diseases || {}).forEach(collect)
+ Object.values(data.other_causes || {}).forEach(collect)
+
+ const all = [...urls]
+ const total = all.length
+ let done = 0
+
+ await broadcast({ type: 'precache-progress', done, total })
+
+ const cache = await caches.open(CACHE_NAME)
+ let index = 0
+ async function worker() {
+ while (index < all.length) {
+ const url = all[index++]
+ if (!(await cache.match(url))) {
+ await cache.add(url).catch(() => {})
+ }
+ done++
+ if (done % 10 === 0 || done === total) {
+ await broadcast({ type: 'precache-progress', done, total })
+ }
+ }
+ }
+ await Promise.all(Array.from({ length: PRECACHE_CONCURRENCY }, worker))
+ } catch {} finally {
+ precachingLangs.delete(lang)
+ }
+}
diff --git a/scripts/verify-build.js b/scripts/verify-build.js
new file mode 100644
index 0000000..da90365
--- /dev/null
+++ b/scripts/verify-build.js
@@ -0,0 +1,93 @@
+#!/usr/bin/env node
+// Post-build smoke test: confirms the dist/ artefacts are coherent.
+// Run AFTER `npm run build`. Used in CI (and locally) to catch regressions
+// in the PWA/SW pipeline without spinning up a browser.
+//
+// Checks:
+// - expected files exist (index.html, sw.js, .vite/manifest.json, favicon)
+// - sw.js parses as valid JavaScript and references the vite manifest
+// - hashed JS/CSS bundles in index.html are present in the vite manifest
+// (so the SW precache list and the actual page payload agree)
+
+import { readFileSync, existsSync } from 'node:fs'
+import { Script } from 'node:vm'
+import { join, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const DIST = join(__dirname, '../dist')
+
+let failures = 0
+
+function check(label, ok, detail = '') {
+ const mark = ok ? '✓' : '✗'
+ const suffix = ok ? '' : `\n ${detail}`
+ console.log(`${mark} ${label}${suffix}`)
+ if (!ok) failures++
+}
+
+function readText(path) {
+ try { return readFileSync(path, 'utf8') } catch { return null }
+}
+
+// 1. Required artefacts
+const required = [
+ 'index.html',
+ 'sw.js',
+ '.vite/manifest.json',
+ 'assets/favicon.ico',
+]
+for (const f of required) {
+ check(`dist/${f} exists`, existsSync(join(DIST, f)), 'file missing — did the build complete?')
+}
+
+// 2. sw.js sanity
+const swSrc = readText(join(DIST, 'sw.js'))
+if (swSrc) {
+ let parseErr = ''
+ try { new Script(swSrc) } catch (e) { parseErr = e.message }
+ check('sw.js parses as valid JavaScript', !parseErr, parseErr)
+ check('sw.js fetches /.vite/manifest.json on install',
+ swSrc.includes('/.vite/manifest.json'),
+ 'expected the SW to read the vite manifest to precache hashed bundles')
+ check('sw.js declares a CACHE_NAME',
+ /CACHE_NAME\s*=\s*['"][^'"]+['"]/.test(swSrc),
+ 'CACHE_NAME constant not found in sw.js')
+}
+
+// 3. index.html ↔ vite manifest consistency
+const indexHtml = readText(join(DIST, 'index.html'))
+const manifestRaw = readText(join(DIST, '.vite/manifest.json'))
+
+if (indexHtml && manifestRaw) {
+ let manifest
+ try { manifest = JSON.parse(manifestRaw) } catch (e) {
+ check('vite manifest is valid JSON', false, e.message)
+ }
+ if (manifest) {
+ const manifestFiles = new Set()
+ for (const entry of Object.values(manifest)) {
+ if (entry.file) manifestFiles.add(entry.file)
+ if (Array.isArray(entry.css)) entry.css.forEach((f) => manifestFiles.add(f))
+ if (Array.isArray(entry.assets)) entry.assets.forEach((f) => manifestFiles.add(f))
+ }
+ check('vite manifest declares at least one bundle', manifestFiles.size > 0)
+
+ const referenced = [...indexHtml.matchAll(/(?:src|href)="\/(assets\/[^"]+\.(?:js|css))"/g)]
+ .map((m) => m[1])
+ check('index.html references at least one hashed bundle', referenced.length > 0,
+ 'no /assets/*.{js,css} found in index.html')
+ for (const ref of referenced) {
+ check(`${ref} is declared in the vite manifest`,
+ manifestFiles.has(ref),
+ 'present in index.html but missing from manifest.json — SW would not precache it')
+ }
+ }
+}
+
+console.log('')
+if (failures) {
+ console.error(`${failures} check(s) failed`)
+ process.exit(1)
+}
+console.log('All checks passed')
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 0000000..9058ca7
--- /dev/null
+++ b/sonar-project.properties
@@ -0,0 +1,11 @@
+sonar.projectKey=ffillouxdev_D-CAS-2.0
+sonar.organization=ffillouxdev
+sonar.projectName=D-CAS 2.0
+sonar.projectVersion=2.0.0
+
+sonar.sources=src
+sonar.exclusions=dist/**,node_modules/**,public/**
+sonar.cpd.exclusions=src/i18n.ts,src/views/about.ts,src/views/privacy.ts,src/views/legal.ts
+sonar.coverage.exclusions=**/*
+sonar.javascript.lcov.reportPaths=coverage/lcov.info
+sonar.qualitygate.wait=false
diff --git a/src/components/breadcrumb.ts b/src/components/breadcrumb.ts
new file mode 100644
index 0000000..3de7aca
--- /dev/null
+++ b/src/components/breadcrumb.ts
@@ -0,0 +1,26 @@
+import { useT } from '../i18n'
+
+export interface BreadcrumbItem {
+ label: string
+ nodeId: string
+}
+
+export function breadcrumb(history: BreadcrumbItem[]): string {
+ const t = useT()
+ const isHomeOnly = history.length === 0
+ const home = /*html*/`${t('nav.home')}`
+
+ const items = history.map((item, i) => {
+ const isLast = i === history.length - 1
+ if (isLast) {
+ return /*html*/`${item.label}`
+ }
+ return /*html*/`${item.label}`
+ })
+
+ return /*html*/`
+
+ `
+}
diff --git a/src/components/call_to_action.ts b/src/components/call_to_action.ts
new file mode 100644
index 0000000..6ee4f98
--- /dev/null
+++ b/src/components/call_to_action.ts
@@ -0,0 +1,32 @@
+import { useT } from '../i18n'
+
+export function callToAction(showCiradBadge = false): string {
+ const t = useT()
+
+ const ciradBadge = showCiradBadge ? /*html*/`
+
+
+
+ ` : ''
+
+ return /*html*/`
+
+ `
+}
diff --git a/src/components/cirad_corner.ts b/src/components/cirad_corner.ts
new file mode 100644
index 0000000..6420393
--- /dev/null
+++ b/src/components/cirad_corner.ts
@@ -0,0 +1,8 @@
+export function ciradCorner(): string {
+ return /*html*/`
+
+
+
+ `
+}
diff --git a/src/components/disease_result.ts b/src/components/disease_result.ts
new file mode 100644
index 0000000..f42b420
--- /dev/null
+++ b/src/components/disease_result.ts
@@ -0,0 +1,163 @@
+import i18next, { useT } from '../i18n'
+import type { Disease } from '../data/key-loader'
+
+const IMG_EXT_RE = /\.(jpe?g|png|webp|gif)$/i
+const NUM_PREFIX_RE = /^[\d.]+\s+/
+const FIGURE_RE = /Figure\s+\d+\s*/gi
+
+function formatFilename(path: string): string {
+ const base = (path.split('/').pop() ?? path).replace(IMG_EXT_RE, '')
+ const sep = base.lastIndexOf('_')
+ if (sep === -1) return base
+ const disease = base.slice(0, sep)
+ .replace(NUM_PREFIX_RE, '')
+ .replace(FIGURE_RE, '')
+ .trim()
+ const photographer = base.slice(sep + 1).trim().replace(IMG_EXT_RE, '')
+ if (!disease) return base
+ return `(© ${photographer})`
+}
+
+export interface DiseaseResultOptions {
+ // Optional markup rendered above the title (e.g. breadcrumb for home,
+ // simple `result_DiseaseName` label for catalogue).
+ topSlot?: string
+}
+
+function carousel(images: string[], diseaseName: string): string {
+ const t = useT()
+ const hasImages = images.length > 0
+ const hasMultiple = images.length > 1
+ const firstSrc = hasImages ? images[0] : ''
+
+ const frame = /*html*/`
+
+ ${hasImages
+ ? /*html*/`

`
+ : /*html*/`
${t('result.noImage')}`
+ }
+
+ `
+
+ // Single image or no images: just the frame, no navigation controls.
+ if (!hasMultiple) {
+ return /*html*/`
+
+ ${frame}
+ ${hasImages ? /*html*/`
${formatFilename(images[0])}
` : ''}
+
+ `
+ }
+
+ // Multiple images: full carousel with arrows.
+ return /*html*/`
+
+
+
+
+ ${frame}
+
+
+
+
+
+
1 / ${images.length}
+
${images.length > 0 ? formatFilename(images[0]) : ''}
+
+
+ `
+}
+
+function geoZones(geo: Disease['geo_locations']): string {
+ const t = useT()
+ if (!geo || geo.length === 0) return ''
+
+ const details: string[] = []
+ for (const entry of geo) {
+ for (const [continent, countries] of Object.entries(entry)) {
+ const countries_html = countries.map(c => /*html*/`${c}`).join('')
+ details.push(/*html*/`
+
+ ${continent}
+
+
+ `)
+ }
+ }
+
+ return /*html*/`
+
+ ${t('result.geoLocations')} 🌐 :
+ ⚠️ — ${t('result.geoWarning')} ${t('result.geoWarningEmail')}
+ ${details.join('')}
+
+ `
+}
+
+export function renderPathogen(text: string): string {
+ return text.replace(/\*([^*]+)\*/g, '$1')
+}
+
+// Builds the `result_` breadcrumb label shown above the disease view.
+// Strips cross-reference parentheticals (e.g. "(see also X; Y)") that bloat
+// names like "Basal stem, root and sheath rot(see also ...; ...; ...)", then
+// caps to 40 chars with an ellipsis as a safety net.
+const PARENTHETICAL_RE = /\([^)]*\)/g
+const RESULT_LABEL_MAX = 40
+export function formatResultLabel(name: string): string {
+ let compact = name.replace(PARENTHETICAL_RE, '').replace(/\s+/g, ' ').trim()
+ if (compact.length > RESULT_LABEL_MAX) {
+ compact = compact.slice(0, RESULT_LABEL_MAX).trimEnd() + '…'
+ }
+ return `${i18next.t('result.prefix')}_${compact}`
+}
+
+export function diseaseResult(disease: Disease, opts: DiseaseResultOptions = {}): string {
+ const top = opts.topSlot ?? ''
+
+ return /*html*/`
+ ${top}
+
+ ${disease.name}
+
+ ${disease.pathogen ? /*html*/`${renderPathogen(disease.pathogen)}
` : ''}
+ ${carousel(disease.image ?? [], disease.name)}
+ ${geoZones(disease.geo_locations)}
+ `
+}
+
+// Attach carousel navigation behaviour to a root element containing a
+// [data-carousel] block rendered by `diseaseResult`. Safe to call when no
+// carousel is present — it will no-op.
+export function bindCarousel(root: ParentNode, images: string[]): void {
+ if (images.length <= 1) return
+
+ const container = root.querySelector('[data-carousel]')
+ if (!container) return
+
+ const img = container.querySelector('[data-carousel-img]') as HTMLImageElement | null
+ const idxEl = container.querySelector('[data-carousel-index]')
+ const filenameEl = container.querySelector('[data-carousel-filename]')
+ const prev = container.querySelector('[data-carousel-prev]') as HTMLButtonElement | null
+ const next = container.querySelector('[data-carousel-next]') as HTMLButtonElement | null
+
+ let idx = 0
+
+ function show(i: number): void {
+ idx = (i + images.length) % images.length
+ if (img) img.src = images[idx]
+ if (idxEl) idxEl.textContent = String(idx + 1)
+ if (filenameEl) filenameEl.textContent = formatFilename(images[idx])
+ }
+
+ prev?.addEventListener('click', () => show(idx - 1))
+ next?.addEventListener('click', () => show(idx + 1))
+}
diff --git a/src/components/header.ts b/src/components/header.ts
new file mode 100644
index 0000000..db08368
--- /dev/null
+++ b/src/components/header.ts
@@ -0,0 +1,248 @@
+import i18next, { useT } from '../i18n'
+
+const languages = [
+ { code: 'en', flag: '🇬🇧', label: 'english' },
+ { code: 'fr', flag: '🇫🇷', label: 'français' },
+ { code: 'es', flag: '🇪🇸', label: 'español' },
+]
+
+const localizedPaths: Record> = {
+ catalogue: { en: '/catalog', fr: '/catalogue', es: '/catalogo' },
+ about: { en: '/about', fr: '/a-propos', es: '/acerca-de' },
+ 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 ?? '/'
+}
+
+function isActive(path: string): boolean {
+ const pathname = globalThis.location.pathname
+ if (path === '/') return pathname === '/'
+ return Object.values(localizedPaths[path] || {}).includes(pathname)
+}
+
+export function header(): string {
+ const t = useT()
+ const lang = languages.find(l => l.code === i18next.language) ?? languages[0]
+ const offlineChecked = isOfflineEnabled() ? 'checked' : ''
+
+ const options = languages.map(({ code, flag, label }) =>
+ ``
+ ).join('')
+
+ const linkClass = (key: string) => {
+ const active = isActive(key)
+ return active
+ ? 'text-green-700 font-bold'
+ : 'text-gray-700 hover:text-green-700'
+ }
+
+ const mobileNavClass = (key: string) => {
+ const active = isActive(key)
+ return active
+ ? 'flex flex-col items-center gap-0.5 text-green-700'
+ : 'flex flex-col items-center gap-0.5 text-gray-500'
+ }
+
+ return /*html*/`
+
+
+
+
+
+
+
+
+ `
+}
+
+function isOfflineEnabled(): boolean {
+ return localStorage.getItem('dcas-offline') !== 'false'
+}
+
+async function registerSW(): Promise {
+ if (!('serviceWorker' in navigator)) return
+ await navigator.serviceWorker.register('/sw.js')
+ await navigator.serviceWorker.ready
+ requestImagePrecache()
+}
+
+function requestImagePrecache(): void {
+ const sw = navigator.serviceWorker.controller
+ if (!sw) return
+ sw.postMessage({ type: 'precache-images', lang: i18next.language })
+}
+
+async function unregisterSW(): Promise {
+ if (!('serviceWorker' in navigator)) return
+ const registrations = await navigator.serviceWorker.getRegistrations()
+ for (const reg of registrations) {
+ await reg.unregister()
+ }
+ const keys = await caches.keys()
+ for (const key of keys) {
+ await caches.delete(key)
+ }
+}
+
+export function initOffline(): void {
+ if (isOfflineEnabled()) {
+ registerSW()
+ }
+
+ // Outside-click handler: closes any open dropdown. Registered ONCE for the
+ // lifetime of the page — re-binding on every router render would leak.
+ document.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement
+ const menuDropdown = document.getElementById('menu-dropdown')
+ const mobileMenuDropdown = document.getElementById('mobile-menu-dropdown')
+ if (menuDropdown && !menuDropdown.contains(target)) menuDropdown.classList.add('hidden')
+ if (mobileMenuDropdown && !mobileMenuDropdown.contains(target)) mobileMenuDropdown.classList.add('hidden')
+ })
+
+ // SW precache progress messages: registered ONCE on a persistent target
+ // (navigator.serviceWorker). Re-binding on every render would leak handlers.
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.addEventListener('message', (event) => {
+ if (event.data?.type !== 'precache-progress') return
+ const { done, total } = event.data
+ const ready = done >= total
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0
+ const text = ready
+ ? i18next.t('nav.offlineReady')
+ : `${i18next.t('nav.offlineDownloading')} ${done}/${total}`
+ document.querySelectorAll('.offline-progress').forEach((el) => {
+ el.classList.remove('hidden')
+ const textEl = el.querySelector('.offline-progress-text')
+ const barEl = el.querySelector('.offline-progress-bar')
+ if (textEl) textEl.textContent = text
+ if (barEl) barEl.style.width = `${ready ? 100 : pct}%`
+ if (ready) setTimeout(() => el.classList.add('hidden'), 4000)
+ })
+ })
+ }
+}
+
+function bindOfflineToggle(id: string): void {
+ const toggle = document.getElementById(id) as HTMLInputElement | null
+ if (!toggle) return
+ toggle.checked = isOfflineEnabled()
+ toggle.addEventListener('change', () => {
+ const enabled = toggle.checked
+ localStorage.setItem('dcas-offline', enabled ? 'true' : 'false')
+ // Sync the other toggle
+ const otherId = id === 'offline-toggle' ? 'mobile-offline-toggle' : 'offline-toggle'
+ const other = document.getElementById(otherId) as HTMLInputElement | null
+ if (other) other.checked = enabled
+ if (enabled) { registerSW() } else { unregisterSW() }
+ })
+}
+
+export function bindHeaderEvents(onLangChange: () => void): void {
+ const select = document.getElementById('lang-select') as HTMLSelectElement | null
+ select?.addEventListener('change', () => {
+ i18next.changeLanguage(select.value).then(() => {
+ onLangChange()
+ requestImagePrecache()
+ })
+ })
+
+ // Desktop dropdown
+ const menuBtn = document.getElementById('menu-btn')
+ const menuDropdown = document.getElementById('menu-dropdown')
+ menuBtn?.addEventListener('click', (e) => {
+ e.stopPropagation()
+ menuDropdown?.classList.toggle('hidden')
+ })
+
+ // Mobile dropdown
+ const mobileMenuBtn = document.getElementById('mobile-menu-btn')
+ const mobileMenuDropdown = document.getElementById('mobile-menu-dropdown')
+ mobileMenuBtn?.addEventListener('click', (e) => {
+ e.stopPropagation()
+ mobileMenuDropdown?.classList.toggle('hidden')
+ })
+
+ // Offline toggles
+ bindOfflineToggle('offline-toggle')
+ bindOfflineToggle('mobile-offline-toggle')
+}
diff --git a/src/components/question_button.ts b/src/components/question_button.ts
new file mode 100644
index 0000000..ec61d60
--- /dev/null
+++ b/src/components/question_button.ts
@@ -0,0 +1,7 @@
+export function questionButton(label: string, next: string): string {
+ return /*html*/`
+
+ `
+}
diff --git a/src/components/questionnaire.ts b/src/components/questionnaire.ts
new file mode 100644
index 0000000..f61b535
--- /dev/null
+++ b/src/components/questionnaire.ts
@@ -0,0 +1,181 @@
+import { useT } from '../i18n'
+import { loadKey, type IdentificationKey } from '../data/key-loader'
+import { questionButton } from './question_button'
+import { breadcrumb, type BreadcrumbItem } from './breadcrumb'
+import { diseaseResult, bindCarousel, formatResultLabel } from './disease_result'
+
+interface State {
+ key: IdentificationKey | null
+ currentNodeId: string
+ history: BreadcrumbItem[]
+ listenerAttached: boolean
+}
+
+const state: State = {
+ key: null,
+ currentNodeId: 'root',
+ history: [],
+ listenerAttached: false,
+}
+
+function backButton(): string {
+ if (state.history.length === 0) return ''
+ const t = useT()
+ return /*html*/`
+
+ `
+}
+
+function goBack(): void {
+ if (state.history.length <= 1) {
+ navigateToBreadcrumb('root')
+ } else {
+ const prev = state.history[state.history.length - 2]
+ navigateToBreadcrumb(prev.nodeId)
+ }
+}
+
+function renderQuestion(): string {
+ if (!state.key) return ''
+
+ const node = state.key.nodes[state.currentNodeId]
+ if (!node) return ''
+
+ const buttons = node.options.map(opt => questionButton(opt.label, opt.next)).join('')
+
+ return /*html*/`
+ ${backButton()}
+ ${breadcrumb(state.history)}
+
+ ${node.text}
+
+
+ ${buttons}
+
+ `
+}
+
+function lookupDisease(id: string) {
+ if (!state.key) return undefined
+ return state.key.diseases[id] ?? state.key.other_causes?.[id]
+}
+
+function renderDisease(): string {
+ const disease = lookupDisease(state.currentNodeId)
+ if (!disease) return ''
+
+ return diseaseResult(disease, { topSlot: backButton() + breadcrumb(state.history) })
+}
+
+function update(): void {
+ const container = document.getElementById('questionnaire')
+ if (!container) return
+
+ const isDisease = state.currentNodeId.startsWith('D_')
+ const html = isDisease ? renderDisease() : renderQuestion()
+
+ // Create temporary container and parse HTML to preserve event listeners on container
+ const temp = document.createElement('div')
+ temp.innerHTML = html
+
+ // Replace container's children while keeping the container itself intact
+ container.replaceChildren(...temp.childNodes)
+
+ if (isDisease) {
+ const disease = lookupDisease(state.currentNodeId)
+ if (disease) bindCarousel(container, disease.image ?? [])
+ }
+}
+
+function navigateTo(nodeId: string, label: string): void {
+ state.currentNodeId = nodeId
+
+ // For diseases, add result item to breadcrumb only
+ if (nodeId.startsWith('D_') && state.key) {
+ const diseaseName = lookupDisease(nodeId)?.name || label
+ const resultLabel = formatResultLabel(diseaseName)
+ state.history.push({ label: resultLabel, nodeId })
+ } else {
+ state.history.push({ label, nodeId })
+ }
+
+ update()
+}
+
+function truncateBreadcrumbLabel(label: string, maxLength: number = 40): string {
+ // Find first punctuation mark (period, comma, semicolon)
+ const punctIndex = label.search(/[.,;]/)
+ if (punctIndex > 0 && punctIndex < maxLength) {
+ return label.substring(0, punctIndex)
+ }
+ // Otherwise truncate at maxLength
+ if (label.length > maxLength) {
+ return label.substring(0, maxLength)
+ }
+ return label
+}
+
+function navigateToBreadcrumb(nodeId: string): void {
+ if (nodeId === 'root') {
+ state.history = []
+ state.currentNodeId = 'root'
+ } else {
+ const idx = state.history.findIndex(item => item.nodeId === nodeId)
+ if (idx >= 0) {
+ state.history = state.history.slice(0, idx + 1)
+ state.currentNodeId = nodeId
+ }
+ }
+ update()
+}
+
+function handleClick(e: MouseEvent): void {
+ // Handle back button click
+ if ((e.target as HTMLElement).closest('[data-go-back]')) {
+ e.preventDefault()
+ e.stopPropagation()
+ goBack()
+ return
+ }
+
+ // Handle question button click
+ const btn = (e.target as HTMLElement).closest('.question-btn') as HTMLElement | null
+ if (btn) {
+ const next = btn.dataset.next
+ let label = btn.textContent?.trim() ?? ''
+ // Truncate label for breadcrumb (only for questions, not diseases)
+ if (next && !next.startsWith('D_')) {
+ label = truncateBreadcrumbLabel(label)
+ }
+ if (next) navigateTo(next, label)
+ return
+ }
+
+ // Handle breadcrumb click — stop propagation so the router doesn't intercept
+ const crumb = (e.target as HTMLElement).closest('[data-breadcrumb]') as HTMLElement | null
+ if (crumb) {
+ e.preventDefault()
+ e.stopPropagation()
+ const nodeId = crumb.dataset.breadcrumb
+ if (nodeId) navigateToBreadcrumb(nodeId)
+ }
+}
+
+export async function initQuestionnaire(): Promise {
+ // Reset state on each home render
+ state.currentNodeId = 'root'
+ state.history = []
+ state.listenerAttached = false
+
+ state.key = await loadKey()
+ update()
+
+ const container = document.getElementById('questionnaire')
+ if (!container || state.listenerAttached) return
+
+ container.addEventListener('click', handleClick)
+ state.listenerAttached = true
+}
diff --git a/src/data/key-loader.ts b/src/data/key-loader.ts
new file mode 100644
index 0000000..f389621
--- /dev/null
+++ b/src/data/key-loader.ts
@@ -0,0 +1,60 @@
+import i18next from '../i18n'
+
+export interface KeyNode {
+ type: string
+ text: string
+ options: Array<{ label: string; next: string }>
+}
+
+export interface Disease {
+ name: string
+ image: string[]
+ pathogen?: string
+ geo_locations?: Array>
+}
+
+export interface IdentificationKey {
+ nodes: Record
+ diseases: Record
+ other_causes?: Record
+}
+
+const fileMap: Record = {
+ en: '/datas/identification-key.json',
+ fr: '/datas/cle-identification.json',
+ es: '/datas/clave-de-identificacion.json',
+}
+
+let cache: IdentificationKey | null = null
+let cachedLang: string | null = null
+
+async function fetchKey(path: string): Promise {
+ try {
+ const res = await fetch(path)
+ if (!res.ok) return null
+ const data = await res.json()
+ if (!data?.nodes) return null
+ return data as IdentificationKey
+ } catch {
+ return null
+ }
+}
+
+export async function loadKey(): Promise {
+ const lang = i18next.language
+ if (cache && cachedLang === lang) return cache
+
+ const path = fileMap[lang] ?? fileMap.en
+ let data = await fetchKey(path)
+
+ // Fallback to English if the language file is missing or empty
+ if (!data && lang !== 'en') {
+ data = await fetchKey(fileMap.en)
+ }
+
+ if (!data) throw new Error('Failed to load identification key')
+
+ cache = data
+ cachedLang = lang
+ return cache
+}
diff --git a/src/i18n.ts b/src/i18n.ts
new file mode 100644
index 0000000..2f8933c
--- /dev/null
+++ b/src/i18n.ts
@@ -0,0 +1,304 @@
+import i18next from 'i18next'
+import LanguageDetector from 'i18next-browser-languagedetector'
+
+const resources = {
+ en: {
+ translation: {
+ nav: {
+ home: 'Home',
+ catalogue: 'Catalog',
+ more: 'More',
+ about: 'About',
+ offline: 'Available offline',
+ offlineDownloading: 'Downloading',
+ offlineReady: 'Ready for offline use ✓',
+ privacy: 'Privacy',
+ legal: 'Legal notice',
+ },
+ about: {
+ title: 'About',
+ introHeading: 'About this website',
+ introBody: 'This website on identification of sugarcane diseases is an interactive and easy-to-use tool containing an identification system for sugarcane diseases and disorders distributed worldwide. It is based on the book "A guide to sugarcane diseases" (second edition) published by QUAE (Philippe Rott, Jean-Claude Girard, and Jean Heinrich Daugrois, scientific editors). This scientific knowledge base contains the description of more than 80 diseases and disorders of sugarcane including 500 colour photographs.',
+ creditsHeading: 'Credits',
+ creditsDevelopment: 'Developed by FILLOUX Florian under the direction of MAHÉ Frédéric and ROTT Philippe.',
+ creditsPhotographers: 'We wish to thank all the persons who contributed photos to the website.',
+ creditsCiradIntro: 'Published by',
+ creditsCiradName: 'CIRAD',
+ creditsCiradDescription: 'French agricultural research and cooperation organisation working for the sustainable development of tropical and Mediterranean regions.',
+ creditsCiradLink: 'Visit cirad.fr',
+ },
+ home: {
+ title: 'Where do you observe the symptoms on the sugarcane?',
+ 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.',
+ searchPlaceholder: 'Search for a disease by name...',
+ diseaseNameColumn: 'Common name',
+ pathogenColumn: 'Pathogen name (Syn. = Synonymy*)',
+ pathogenColumnShort: 'Pathogen name',
+ pathogenSynonym: '(Syn. = Synonymy*)',
+ linkLabel: 'see more +',
+ closeLabel: 'Close',
+ backToList: '← Back to the list',
+ sectionDiseases: 'Diseases',
+ sectionOtherCauses: 'Other causes',
+ },
+ result: {
+ noImage: 'No image available
(please contact the webmaster at sugarcane-diseases@cirad.fr if you have a photograph for this disease or disorder)',
+ prevImage: 'Previous image',
+ nextImage: 'Next image',
+ geoLocations: 'Geographical locations',
+ geoWarning: 'If you observe these symptoms in a location not mentioned below, please contact us!',
+ geoWarningEmail: 'sugarcane-diseases@cirad.fr',
+ prefix: 'Result',
+ },
+ cta: {
+ intro: 'For additionnal information, please see the book',
+ bookTitle: 'A guide to sugarcane diseases',
+ authors: 'edited by Philippe ROTT, Jean-Claude GIRARD and Jean Heinrich DAUGROIS',
+ buttonMobile: 'Web book →',
+ buttonDesktop: 'The free web book →',
+ },
+ contact: {
+ heading: 'Contact us',
+ body: 'For any questions, information or photographs of sugarcane diseases, please contact us at',
+ },
+ 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.',
+ copyrightHeading: 'Copyright',
+ copyrightBody: '© CIRAD. All rights reserved. Disease content and images are the property of CIRAD and contributing photographers. Any reproduction, redistribution or commercial use without prior written authorization is prohibited.',
+ },
+ questionnaire: {
+ prevQuestion: '← Previous question',
+ },
+ notFound: 'Page not found',
+ backHome: 'Back to home',
+ },
+ },
+ fr: {
+ translation: {
+ nav: {
+ home: 'Accueil',
+ catalogue: 'Catalogue',
+ more: 'Plus',
+ about: 'À propos',
+ offline: 'Accessible hors connexion',
+ offlineDownloading: 'Téléchargement',
+ offlineReady: 'Prêt hors connexion ✓',
+ privacy: 'Confidentialité',
+ legal: 'Mentions légales',
+ },
+ about: {
+ title: 'À propos',
+ introHeading: 'À propos de ce guide',
+ introBody: "Ce guide web sur les maladies de la canne à sucre est un outil interactif et facile à utiliser, contenant un système d'identification de maladies et désordres affectant la canne à sucre dans le monde entier. Il est basé sur l'ouvrage « A guide to sugarcane diseases » (seconde édition) publié par QUAE (Philippe Rott, Jean-Claude Girard et Jean Heinrich Daugrois, éditeurs scientifiques). Cette base de connaissances scientifique contient la description de plus de 80 maladies et désordres de la canne à sucre, ainsi que 500 photographies couleur.",
+ creditsHeading: 'Crédits',
+ creditsDevelopment: 'Développé par FILLOUX Florian sous la direction de MAHÉ Frédéric et de ROTT Philippe.',
+ creditsPhotographers: 'Nous tenons à remercier toutes les personnes qui ont contribué des photos au guide web.',
+ creditsCiradIntro: 'Édité par',
+ creditsCiradName: 'CIRAD',
+ creditsCiradDescription: 'Centre de coopération internationale en recherche agronomique pour le développement, œuvrant pour le développement durable des régions tropicales et méditerranéennes.',
+ creditsCiradLink: 'Visiter cirad.fr',
+ },
+ home: {
+ title: 'Où observez-vous les symptômes sur la canne à sucre ?',
+ 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é.',
+ searchPlaceholder: 'Rechercher une maladie par nom...',
+ diseaseNameColumn: 'Nom commun',
+ pathogenColumn: 'Nom agent pathogène (Syn. = Synonymy*)',
+ pathogenColumnShort: 'Nom agent pathogène',
+ pathogenSynonym: '(Syn. = Synonymie*)',
+ linkLabel: 'voir plus +',
+ closeLabel: 'Fermer',
+ backToList: '← Retour à la liste',
+ sectionDiseases: 'Maladies',
+ sectionOtherCauses: 'Autres causes',
+ },
+ result: {
+ noImage: 'Aucune image disponible
(veuillez contacter le webmaster à sugarcane-diseases@cirad.fr si vous avez une photographie pour cette maladie ou ce désordre)',
+ prevImage: 'Image précédente',
+ nextImage: 'Image suivante',
+ geoLocations: 'Localisations géographiques',
+ geoWarning: "Si vous observez ces symptômes dans un lieu non mentionné ci-dessous, contactez-nous !",
+ geoWarningEmail: 'sugarcane-diseases@cirad.fr',
+ prefix: 'Résultat',
+ },
+ cta: {
+ intro: 'Pour plus d\'informations, consultez l\'ouvrage',
+ bookTitle: 'A guide to sugarcane diseases',
+ authors: 'édité par Philippe ROTT, Jean-Claude GIRARD et Jean Heinrich DAUGROIS',
+ buttonMobile: 'Web book →',
+ buttonDesktop: 'Consulter l\'ouvrage gratuit →',
+ },
+ contact: {
+ heading: 'Contactez-nous',
+ body: 'Pour toute question, information ou photographie de maladies de la canne à sucre, veuillez nous contacter à',
+ },
+ 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.",
+ copyrightHeading: 'Droits d\'auteur',
+ copyrightBody: '© CIRAD. Tous droits réservés. Les contenus et images des maladies sont la propriété du CIRAD et des photographes contributeurs. Toute reproduction, redistribution ou utilisation commerciale sans autorisation écrite préalable est interdite.',
+ },
+ questionnaire: {
+ prevQuestion: '← Question précédente',
+ },
+ notFound: 'Page introuvable',
+ backHome: "Retour à l'accueil",
+ },
+ },
+ es: {
+ translation: {
+ nav: {
+ home: 'Inicio',
+ catalogue: 'Catálogo',
+ more: 'Más',
+ about: 'Acerca de',
+ offline: 'Disponible sin conexión',
+ offlineDownloading: 'Descargando',
+ offlineReady: 'Listo sin conexión ✓',
+ privacy: 'Privacidad',
+ legal: 'Aviso legal',
+ },
+ about: {
+ title: 'Acerca de',
+ introHeading: 'Acerca de este sitio web',
+ introBody: 'Este sitio web sobre identificación de enfermedades de la caña de azúcar es una herramienta interactiva y fácil de usar, que contiene un sistema de identificación de enfermedades y desórdenes que afectan a la caña de azúcar en todo el mundo. Está basado en el libro «A guide to sugarcane diseases» (segunda edición) publicado por QUAE (Philippe Rott, Jean-Claude Girard y Jean Heinrich Daugrois, editores científicos). Esta base de conocimientos científicos contiene la descripción de más de 80 enfermedades y desórdenes de la caña de azúcar, junto con 500 fotografías en color.',
+ creditsHeading: 'Créditos',
+ creditsDevelopment: 'Desarrollado por FILLOUX Florian bajo la dirección de MAHÉ Frédéric y ROTT Philippe.',
+ creditsPhotographers: 'Agradecemos a todas las personas que contribuyeron con fotos al sitio web.',
+ creditsCiradIntro: 'Editado por',
+ creditsCiradName: 'CIRAD',
+ creditsCiradDescription: 'Centro de cooperación internacional en investigación agronómica para el desarrollo, dedicado al desarrollo sostenible de las regiones tropicales y mediterráneas.',
+ creditsCiradLink: 'Visitar cirad.fr',
+ },
+ home: {
+ title: '¿Dónde observa los síntomas en la caña de azúcar?',
+ 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.',
+ searchPlaceholder: 'Buscar una enfermedad por nombre...',
+ diseaseNameColumn: 'Nombre común',
+ pathogenColumn: 'Nombre agente patógeno (Syn. = Synonymy*)',
+ pathogenColumnShort: 'Nombre agente patógeno',
+ pathogenSynonym: '(Sin. = Sinonimia*)',
+ linkLabel: 'ver más +',
+ closeLabel: 'Cerrar',
+ backToList: '← Volver a la lista',
+ sectionDiseases: 'Enfermedades',
+ sectionOtherCauses: 'Otras causas',
+ },
+ result: {
+ noImage: 'Sin imagen disponible
(por favor, contacte al webmaster en sugarcane-diseases@cirad.fr si dispone de una fotografía para esta enfermedad o desorden)',
+ prevImage: 'Imagen anterior',
+ nextImage: 'Imagen siguiente',
+ geoLocations: 'Ubicaciones geográficas',
+ geoWarning: 'Si observa estos síntomas en una ubicación no mencionada a continuación, ¡contáctenos!',
+ geoWarningEmail: 'sugarcane-diseases@cirad.fr',
+ prefix: 'Resultado',
+ },
+ cta: {
+ intro: 'Para más información, consulte el libro',
+ bookTitle: 'A guide to sugarcane diseases',
+ authors: 'editado por Philippe ROTT, Jean-Claude GIRARD y Jean Heinrich DAUGROIS',
+ buttonMobile: 'Web book →',
+ buttonDesktop: 'Consultar el libro gratuito →',
+ },
+ contact: {
+ heading: 'Contáctenos',
+ body: 'Para cualquier pregunta, información o fotografía de enfermedades de la caña de azúcar, contáctenos en',
+ },
+ 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.',
+ copyrightHeading: 'Derechos de autor',
+ copyrightBody: '© CIRAD. Todos los derechos reservados. Los contenidos e imágenes de las enfermedades son propiedad del CIRAD y de los fotógrafos contribuyentes. Se prohíbe toda reproducción, redistribución o uso comercial sin autorización escrita previa.',
+ },
+ questionnaire: {
+ prevQuestion: '← Pregunta anterior',
+ },
+ notFound: 'Página no encontrada',
+ backHome: 'Volver al inicio',
+ },
+ },
+}
+
+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
+
+export function useT() {
+ return i18next.t.bind(i18next)
+}
diff --git a/src/layout.ts b/src/layout.ts
new file mode 100644
index 0000000..22a4a9f
--- /dev/null
+++ b/src/layout.ts
@@ -0,0 +1 @@
+export { header, bindHeaderEvents, initOffline } from './components/header'
diff --git a/src/main.ts b/src/main.ts
index 10be2f3..2eb75b2 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,10 +1,7 @@
import './style.css'
+import { initRouter } from './router'
+import { initOffline } from './layout'
-document.querySelector('#app')!.innerHTML = /*html*/ `
-
-
- Guide to Sugarcane Diseases
-
-
-`
-
+const app = document.querySelector('#app')!
+initRouter(app)
+initOffline()
diff --git a/src/router.ts b/src/router.ts
new file mode 100644
index 0000000..69f24eb
--- /dev/null
+++ b/src/router.ts
@@ -0,0 +1,87 @@
+import i18next from './i18n'
+import { bindHeaderEvents, header } from './layout'
+import { homeView } from './views/home'
+import { catalogueView, initCatalogue } from './views/catalog'
+import { aboutView } from './views/about'
+import { privacyView } from './views/privacy'
+import { legalView } from './views/legal'
+import { initQuestionnaire } from './components/questionnaire'
+
+type Route = {
+ path: string
+ titleKey: string
+ view: () => string
+ init?: () => void
+}
+
+const notFoundView = () => {
+ const t = i18next.t.bind(i18next)
+ return /*html*/`
+ ${header()}
+
+ ${t('notFound')}
+
+
+ `
+}
+
+const routes: Route[] = [
+ { path: '/', titleKey: 'home.title', view: homeView, init: initQuestionnaire },
+ { path: '/catalogue', titleKey: 'catalogue.title', view: catalogueView, init: initCatalogue },
+ { path: '/catalog', titleKey: 'catalogue.title', view: catalogueView, init: initCatalogue },
+ { path: '/catalogo', titleKey: 'catalogue.title', view: catalogueView, init: initCatalogue },
+ { path: '/about', titleKey: 'about.title', view: aboutView },
+ { path: '/a-propos', titleKey: 'about.title', view: aboutView },
+ { path: '/acerca-de', titleKey: 'about.title', view: aboutView },
+ { path: '/privacy', titleKey: 'privacy.title', view: privacyView },
+ { path: '/confidentialite', titleKey: 'privacy.title', view: privacyView },
+ { path: '/privacidad', titleKey: 'privacy.title', view: privacyView },
+ { path: '/legal', titleKey: 'legal.title', view: legalView },
+ { path: '/mentions-legales', titleKey: 'legal.title', view: legalView },
+ { path: '/aviso-legal', titleKey: 'legal.title', view: legalView },
+]
+
+function resolve(pathname: string): Route {
+ return routes.find(r => r.path === pathname) ?? { path: pathname, titleKey: 'notFound', view: notFoundView }
+}
+
+export function render(app: HTMLElement): void {
+ const route = resolve(globalThis.location.pathname)
+ const t = i18next.t.bind(i18next)
+
+ document.title = `${t(route.titleKey)} — CaneDr`
+ document.documentElement.lang = i18next.language
+
+ app.innerHTML = route.view()
+
+ bindHeaderEvents(() => render(app))
+
+ route.init?.()
+}
+
+export function navigateTo(path: string, app: HTMLElement): void {
+ history.pushState(null, '', path)
+ render(app)
+}
+
+const EXTERNAL_HREF_RE = /^(https?:|\/\/|#|mailto:|tel:)/
+
+export function initRouter(app: HTMLElement): void {
+ globalThis.addEventListener('popstate', () => render(app))
+
+ document.addEventListener('click', (e) => {
+ if (e.defaultPrevented) return
+ if (e.button !== 0) return
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
+ const anchor = (e.target as HTMLElement).closest('a')
+ if (!anchor) return
+ if (anchor.target && anchor.target !== '_self') return
+ if (anchor.hasAttribute('download')) return
+ const href = anchor.getAttribute('href')
+ if (!href || EXTERNAL_HREF_RE.test(href)) return
+ e.preventDefault()
+ navigateTo(href, app)
+ })
+
+ render(app)
+}
diff --git a/src/style.css b/src/style.css
index e69de29..50f841b 100644
--- a/src/style.css
+++ b/src/style.css
@@ -0,0 +1,61 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@font-face {
+ font-family: 'Frutiger LT Pro';
+ src: url('/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProRoman.otf') format('opentype');
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: 'Frutiger LT Pro';
+ src: url('/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBold.otf') format('opentype');
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: 'Frutiger LT Pro';
+ src: url('/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProItalic.otf') format('opentype');
+ font-weight: 400;
+ font-style: italic;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: 'Frutiger LT Pro';
+ src: url('/assets/fonts/frutiger-lt-pro-cufonfonts/Linotype FrutigerLTProBoldItalic.otf') format('opentype');
+ font-weight: 700;
+ font-style: italic;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: 'Myriad Pro';
+ src: url('/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-REGULAR.OTF') format('opentype');
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: 'Myriad Pro';
+ src: url('/assets/fonts/myriad-pro-cufonfonts/MYRIADPRO-BOLD.OTF') format('opentype');
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+}
+
+@layer base {
+ body {
+ font-family: 'Myriad Pro', ui-sans-serif, system-ui, sans-serif;
+ }
+
+ h1, h2, h3, h4, h5, h6 {
+ font-family: 'Frutiger LT Pro', ui-sans-serif, system-ui, sans-serif;
+ }
+}
diff --git a/src/views/about.ts b/src/views/about.ts
new file mode 100644
index 0000000..b8e1e93
--- /dev/null
+++ b/src/views/about.ts
@@ -0,0 +1,44 @@
+import { useT } from '../i18n'
+import { header } from '../layout'
+
+export function aboutView(): string {
+ const t = useT()
+
+ return /*html*/`
+ ${header()}
+
+
+
+ ${t('about.introHeading')}
+ ${t('about.introBody')}
+
+
+
+ ${t('about.creditsHeading')}
+ ${t('about.creditsDevelopment')}
+ ${t('about.creditsPhotographers')}
+
+ ${t('contact.heading')}
+
+ ${t('contact.body')}
+ sugarcane-diseases@cirad.fr
+
+
+
+
+
+
+ `
+}
diff --git a/src/views/catalog.ts b/src/views/catalog.ts
new file mode 100644
index 0000000..df2a506
--- /dev/null
+++ b/src/views/catalog.ts
@@ -0,0 +1,248 @@
+import { useT } from '../i18n'
+import { header } from '../layout'
+import { callToAction } from '../components/call_to_action'
+import { loadKey } from '../data/key-loader'
+import { diseaseResult, bindCarousel, renderPathogen, formatResultLabel } from '../components/disease_result'
+export function catalogueView(): string {
+ const t = useT()
+
+ return /*html*/`
+ ${header()}
+
+
+
+
+
+
+
+
+
+
+ |
+ ${t('catalogue.diseaseNameColumn')} ↕
+ |
+
+ ${t('catalogue.pathogenColumnShort')} ${t('catalogue.pathogenSynonym')} ↕
+ |
+ |
+
+
+
+ | … |
+
+
+
+
+
+
+
+ ${callToAction(true)}
+
+ `
+}
+
+export async function initCatalogue(): Promise {
+ // loadKey() caches the JSON in memory — called once here for the list, and
+ // reused without refetch when rendering each disease result.
+ const key = await loadKey()
+ const t = useT()
+
+ type Disease = (typeof key.diseases)[string]
+ type Entry = Disease & { id: string; section: string }
+ type SortKey = 'name' | 'pathogen'
+ type SortDir = 'asc' | 'desc'
+
+ const diseases: Entry[] = Object.entries(key.diseases).filter(([id]) => id !== 'D_no_convincing_result').map(([id, d]) => ({ ...d, id, section: t('catalogue.sectionDiseases') }))
+ const otherCauses: Entry[] = Object.entries(key.other_causes ?? {}).filter(([id]) => id !== 'D_borer_attack_in_nursery').map(([id, d]) => ({ ...d, id, section: t('catalogue.sectionOtherCauses') }))
+ let sortKey: SortKey = 'name'
+ let sortDir: SortDir = 'asc'
+ let query = ''
+ let selectedSection: string | null = null
+
+ function sortEntries(list: Entry[]): Entry[] {
+ return list.slice().sort((a, b) => {
+ const av = (sortKey === 'name' ? a.name : (a.pathogen ?? '')).toLowerCase()
+ const bv = (sortKey === 'name' ? b.name : (b.pathogen ?? '')).toLowerCase()
+ return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av)
+ })
+ }
+
+ function filterEntries(list: Entry[]): Entry[] {
+ if (!query) return list
+ return list.filter(d =>
+ d.name.toLowerCase().includes(query) ||
+ (d.pathogen ?? '').toLowerCase().includes(query) ||
+ d.section.toLowerCase().includes(query)
+ )
+ }
+
+ function updateSortIndicators(): void {
+ const nameEl = document.getElementById('sort-name')
+ const pathEl = document.getElementById('sort-pathogen')
+ const dirArrow = sortDir === 'asc' ? '↑' : '↓'
+ if (nameEl) nameEl.textContent = sortKey === 'name' ? dirArrow : '↕'
+ if (pathEl) pathEl.textContent = sortKey === 'pathogen' ? dirArrow : '↕'
+ }
+
+ function renderEntryRow(d: Entry): string {
+ return /*html*/`
+
+ | ${d.name} |
+ ${d.pathogen ? renderPathogen(d.pathogen) : '—'} |
+
+
+ |
+
+ `
+ }
+
+ function renderSection(heading: string, list: Entry[]): string {
+ if (list.length === 0) return ''
+ const isSelected = selectedSection === heading
+ return /*html*/`
+
+ |
+ ${heading}
+ |
+
+ ${list.map(renderEntryRow).join('')}
+ `
+ }
+
+ function renderRows(): string {
+ let diseasesView = sortEntries(filterEntries(diseases))
+ let otherView = sortEntries(filterEntries(otherCauses))
+
+ if (selectedSection !== null) {
+ diseasesView = selectedSection === t('catalogue.sectionDiseases') ? diseasesView : []
+ otherView = selectedSection === t('catalogue.sectionOtherCauses') ? otherView : []
+ }
+
+ if (diseasesView.length === 0 && otherView.length === 0) {
+ return `| ${t('catalogue.empty')} |
`
+ }
+ return (
+ renderSection(t('catalogue.sectionDiseases'), diseasesView) +
+ renderSection(t('catalogue.sectionOtherCauses'), otherView)
+ )
+ }
+
+ function refresh(): void {
+ const tbody = document.getElementById('catalogue-list')
+ if (tbody) tbody.innerHTML = renderRows()
+ updateSortIndicators()
+
+ // Hide pathogen header when only other causes are visible
+ let visibleDiseases = filterEntries(diseases)
+ let visibleOther = filterEntries(otherCauses)
+ if (selectedSection !== null) {
+ visibleDiseases = selectedSection === t('catalogue.sectionDiseases') ? visibleDiseases : []
+ visibleOther = selectedSection === t('catalogue.sectionOtherCauses') ? visibleOther : []
+ }
+ const onlyOtherCauses = visibleDiseases.length === 0 && visibleOther.length > 0
+
+ const thPathogen = document.getElementById('th-pathogen')
+ if (thPathogen) {
+ if (onlyOtherCauses) {
+ thPathogen.innerHTML = '×'
+ delete thPathogen.dataset.sort
+ thPathogen.classList.remove('cursor-pointer', 'hover:bg-gray-50')
+ } else {
+ thPathogen.innerHTML = `${t('catalogue.pathogenColumnShort')} ${t('catalogue.pathogenSynonym')} ↕`
+ thPathogen.dataset.sort = 'pathogen'
+ thPathogen.classList.add('cursor-pointer', 'hover:bg-gray-50')
+ }
+ thPathogen.style.display = ''
+ }
+ }
+
+ function showResult(diseaseId: string): void {
+ const disease = key.diseases[diseaseId] ?? key.other_causes?.[diseaseId]
+ if (!disease) return
+
+ const main = document.querySelector('main')
+ const listView = document.getElementById('catalogue-list-view')
+ const resultView = document.getElementById('catalogue-result-view')
+ if (!main || !listView || !resultView) return
+
+ const resultLabel = formatResultLabel(disease.name)
+ const topSlot = /*html*/`
+
+ `
+
+ resultView.innerHTML = diseaseResult(disease, { topSlot })
+ bindCarousel(resultView, disease.image ?? [])
+
+ main.classList.remove('md:h-[calc(100vh-4.5rem)]', 'portrait:h-screen', '[@media(orientation:landscape)_and_(max-height:480px)]:h-[200vh]', 'overflow-hidden', 'flex', 'flex-col')
+ main.classList.add('min-h-[calc(100vh-4.5rem)]')
+ listView.classList.add('hidden')
+ resultView.classList.remove('hidden')
+
+ const back = document.getElementById('catalogue-back')
+ if (back) back.addEventListener('click', showList)
+ }
+
+ function showList(): void {
+ const main = document.querySelector('main')
+ const listView = document.getElementById('catalogue-list-view')
+ const resultView = document.getElementById('catalogue-result-view')
+ if (!main || !listView || !resultView) return
+
+ main.classList.add('md:h-[calc(100vh-4.5rem)]', 'portrait:h-screen', '[@media(orientation:landscape)_and_(max-height:480px)]:h-[200vh]', 'overflow-hidden', 'flex', 'flex-col')
+ main.classList.remove('min-h-[calc(100vh-4.5rem)]')
+ resultView.classList.add('hidden')
+ resultView.innerHTML = ''
+ listView.classList.remove('hidden')
+ }
+
+ refresh()
+
+ const search = document.getElementById('catalogue-search') as HTMLInputElement | null
+ if (search) {
+ search.addEventListener('input', () => {
+ query = search.value.toLowerCase()
+ refresh()
+ })
+ }
+
+ const thead = document.querySelector('thead')
+ if (thead) {
+ thead.addEventListener('click', (e) => {
+ const th = (e.target as HTMLElement).closest('th[data-sort]') as HTMLElement | null
+ if (!th) return
+ const clicked = th.dataset.sort as SortKey
+ if (sortKey === clicked) {
+ sortDir = sortDir === 'asc' ? 'desc' : 'asc'
+ } else {
+ sortKey = clicked
+ sortDir = 'asc'
+ }
+ refresh()
+ })
+ }
+
+ const tbody = document.getElementById('catalogue-list')
+ if (tbody) {
+ tbody.addEventListener('click', (e) => {
+ const sectionFilter = (e.target as HTMLElement).closest('[data-section-filter]') as HTMLElement | null
+ if (sectionFilter) {
+ const section = sectionFilter.dataset.sectionFilter
+ if (section) {
+ selectedSection = selectedSection === section ? null : section
+ refresh()
+ }
+ return
+ }
+
+ const btn = (e.target as HTMLElement).closest('.disease-link-btn') as HTMLElement | null
+ if (!btn) return
+ const id = btn.dataset.diseaseId
+ if (id) showResult(id)
+ })
+ }
+}
diff --git a/src/views/home.ts b/src/views/home.ts
new file mode 100644
index 0000000..1cb7f6a
--- /dev/null
+++ b/src/views/home.ts
@@ -0,0 +1,14 @@
+import { header } from '../layout'
+import { callToAction } from '../components/call_to_action'
+import { ciradCorner } from '../components/cirad_corner'
+
+export function homeView(): string {
+ return /*html*/`
+ ${header()}
+
+ ${ciradCorner()}
+
+ ${callToAction()}
+
+ `
+}
diff --git a/src/views/legal.ts b/src/views/legal.ts
new file mode 100644
index 0000000..34f67f4
--- /dev/null
+++ b/src/views/legal.ts
@@ -0,0 +1,30 @@
+import { useT } from '../i18n'
+import { header } from '../layout'
+
+export function legalView(): string {
+ const t = useT()
+
+ 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') },
+ { heading: t('legal.copyrightHeading'), body: t('legal.copyrightBody') },
+ ]
+
+ const sectionsHtml = sections.map(({ heading, body }) => /*html*/`
+
+ `).join('')
+
+ return /*html*/`
+ ${header()}
+
+
+ ${t('legal.title')}
+ ${sectionsHtml}
+
+
+ `
+}
diff --git a/src/views/privacy.ts b/src/views/privacy.ts
new file mode 100644
index 0000000..51499b9
--- /dev/null
+++ b/src/views/privacy.ts
@@ -0,0 +1,29 @@
+import { useT } from '../i18n'
+import { header } from '../layout'
+
+export function privacyView(): string {
+ const t = useT()
+
+ 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*/`
+
+ `).join('')
+
+ return /*html*/`
+ ${header()}
+
+
+ ${t('privacy.title')}
+ ${sectionsHtml}
+
+
+ `
+}
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..dca8ba0
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,11 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..ac49a9b
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,42 @@
+import { defineConfig } from 'vite'
+import type { Plugin } from 'vite'
+
+// Moves before