feat(i18n): interface trilingue FR/EN/DE — langue PAR UTILISATEUR en base (users.lang, migration 0007), sélecteur dans Réglages, DEFAULT_LANG pour les nouveaux comptes et pages anonymes ; toutes les chaînes UI passent par i18n::Tr (dates et pluriels par langue)Unverified
Claude-Session: https://claude.ai/code/session_01FPKHKGfqvnvXKY9tNhYmkh
8755846 parent: ecdbd69 modified
README.md +8 -2 | @@ -1,7 +1,8 @@ | ||
| 1 | 1 | # time-time |
| 2 | 2 | |
| 3 | 3 | Application web de comptage de temps par catégorie et de tâches partagées, en |
| 4 | -équipe. Mobile-first (PWA), UI en français, rendu 100 % côté serveur — le | |
| 4 | +équipe. Mobile-first (PWA), UI trilingue (français, anglais, allemand), rendu | |
| 5 | +100 % côté serveur — le | |
| 5 | 6 | JavaScript n'existe qu'en amélioration progressive : tout fonctionne par liens |
| 6 | 7 | et formulaires. |
| 7 | 8 | |
| @@ -29,6 +30,10 @@ front, une seule feuille de CSS. | ||
| 29 | 30 | révocation au logout, CSRF stateless, argon2id. |
| 30 | 31 | - **Accessibilité** : WCAG 2.1/2.2 AA — zéro violation axe sur les 12 |
| 31 | 32 | écrans (contrastes, cibles 24 px, landmarks, focus des sheets, Échap). |
| 33 | +- **Trilingue** : français, anglais, allemand — la langue est un réglage | |
| 34 | + **par utilisateur** (page Réglages), stocké en base : le choix suit le | |
| 35 | + compte sur tous les appareils. Français par défaut à la création, | |
| 36 | + modifiable par la variable d'environnement `DEFAULT_LANG`. | |
| 32 | 37 | |
| 33 | 38 | ## Captures d'écran |
| 34 | 39 | |
| @@ -66,6 +71,7 @@ Variables d'environnement du serveur : | ||
| 66 | 71 | | `POSTGRESQL_ADDON_URI` ou `DATABASE_URL` | connexion PostgreSQL (TLS imposé côté client quoi que dise l'URL) | |
| 67 | 72 | | `BISCUIT_PRIVATE_KEY` | clé privée racine (hex, via `keygen`) | |
| 68 | 73 | | `PORT` | port d'écoute (défaut 8080) | |
| 74 | +| `DEFAULT_LANG` | langue des nouveaux comptes et des pages anonymes : `fr` (défaut), `en` ou `de` | | |
| 69 | 75 | |
| 70 | 76 | ### Développement local |
| 71 | 77 | |
| @@ -111,7 +117,7 @@ cargo run -- user add alice --team "Maison" | ||
| 111 | 117 | Notes : |
| 112 | 118 | |
| 113 | 119 | - Le fichier `.clever.json` créé par `clever create`/`clever link` contient |
| 114 | - les identifiants de l'app est **ignoré par git** par défaut. | |
| 120 | + les identifiants de l'app : il est **ignoré par git**, ne pas le committer. | |
| 115 | 121 | - Dimensionnement éprouvé : build flavor `L`, run flavor `nano` |
| 116 | 122 | (`clever scale --build-flavor L --flavor nano`). |
| 117 | 123 | - Le plan PostgreSQL DEV est limité à 5 connexions : le pool de l'app est |
| @@ -1,7 +1,8 @@ | |||
| 1 | # time-time | 1 | # time-time |
| 2 | 2 | ||
| 3 | Application web de comptage de temps par catégorie et de tâches partagées, en | 3 | Application web de comptage de temps par catégorie et de tâches partagées, en |
| 4 | -équipe. Mobile-first (PWA), UI en français, rendu 100 % côté serveur — le | 4 | +équipe. Mobile-first (PWA), UI trilingue (français, anglais, allemand), rendu |
| 5 | +100 % côté serveur — le | ||
| 5 | JavaScript n'existe qu'en amélioration progressive : tout fonctionne par liens | 6 | JavaScript n'existe qu'en amélioration progressive : tout fonctionne par liens |
| 6 | et formulaires. | 7 | et formulaires. |
| 7 | 8 | ||
| @@ -29,6 +30,10 @@ front, une seule feuille de CSS. | |||
| 29 | révocation au logout, CSRF stateless, argon2id. | 30 | révocation au logout, CSRF stateless, argon2id. |
| 30 | - **Accessibilité** : WCAG 2.1/2.2 AA — zéro violation axe sur les 12 | 31 | - **Accessibilité** : WCAG 2.1/2.2 AA — zéro violation axe sur les 12 |
| 31 | écrans (contrastes, cibles 24 px, landmarks, focus des sheets, Échap). | 32 | écrans (contrastes, cibles 24 px, landmarks, focus des sheets, Échap). |
| 33 | +- **Trilingue** : français, anglais, allemand — la langue est un réglage | ||
| 34 | + **par utilisateur** (page Réglages), stocké en base : le choix suit le | ||
| 35 | + compte sur tous les appareils. Français par défaut à la création, | ||
| 36 | + modifiable par la variable d'environnement `DEFAULT_LANG`. | ||
| 32 | 37 | ||
| 33 | ## Captures d'écran | 38 | ## Captures d'écran |
| 34 | 39 | ||
| @@ -66,6 +71,7 @@ Variables d'environnement du serveur : | |||
| 66 | | `POSTGRESQL_ADDON_URI` ou `DATABASE_URL` | connexion PostgreSQL (TLS imposé côté client quoi que dise l'URL) | | 71 | | `POSTGRESQL_ADDON_URI` ou `DATABASE_URL` | connexion PostgreSQL (TLS imposé côté client quoi que dise l'URL) | |
| 67 | | `BISCUIT_PRIVATE_KEY` | clé privée racine (hex, via `keygen`) | | 72 | | `BISCUIT_PRIVATE_KEY` | clé privée racine (hex, via `keygen`) | |
| 68 | | `PORT` | port d'écoute (défaut 8080) | | 73 | | `PORT` | port d'écoute (défaut 8080) | |
| 74 | +| `DEFAULT_LANG` | langue des nouveaux comptes et des pages anonymes : `fr` (défaut), `en` ou `de` | | ||
| 69 | 75 | ||
| 70 | ### Développement local | 76 | ### Développement local |
| 71 | 77 | ||
| @@ -111,7 +117,7 @@ cargo run -- user add alice --team "Maison" | |||
| 111 | Notes : | 117 | Notes : |
| 112 | 118 | ||
| 113 | - Le fichier `.clever.json` créé par `clever create`/`clever link` contient | 119 | - Le fichier `.clever.json` créé par `clever create`/`clever link` contient |
| 114 | - les identifiants de l'app est **ignoré par git** par défaut. | 120 | + les identifiants de l'app : il est **ignoré par git**, ne pas le committer. |
| 115 | - Dimensionnement éprouvé : build flavor `L`, run flavor `nano` | 121 | - Dimensionnement éprouvé : build flavor `L`, run flavor `nano` |
| 116 | (`clever scale --build-flavor L --flavor nano`). | 122 | (`clever scale --build-flavor L --flavor nano`). |
| 117 | - Le plan PostgreSQL DEV est limité à 5 connexions : le pool de l'app est | 123 | - Le plan PostgreSQL DEV est limité à 5 connexions : le pool de l'app est |
modified
assets/style.css +11 -0 | @@ -537,6 +537,17 @@ a.team-pill:hover { filter: brightness(.96); } | ||
| 537 | 537 | .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; } |
| 538 | 538 | .done-sec .done-body { transition: opacity .15s ease; } |
| 539 | 539 | .done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .35; } |
| 540 | +/* Sélecteur de langue (Réglages). */ | |
| 541 | +.lang-pill { | |
| 542 | + display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px; | |
| 543 | + border-radius: 999px; cursor: pointer; font: inherit; font-size: 14px; | |
| 544 | + border: 1px solid var(--color-divider); background: transparent; | |
| 545 | + color: var(--color-text); min-height: 40px; | |
| 546 | +} | |
| 547 | +.lang-pill.current { | |
| 548 | + background: var(--color-accent-2-200); color: var(--color-accent-2-900); | |
| 549 | + border-color: var(--color-accent-2); | |
| 550 | +} | |
| 540 | 551 | /* Carte-lien « Mes saisies » (Réglages). */ |
| 541 | 552 | a.times-link { flex-direction: row; align-items: center; text-decoration: none; color: var(--color-text); } |
| 542 | 553 | |
| @@ -537,6 +537,17 @@ a.team-pill:hover { filter: brightness(.96); } | |||
| 537 | .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; } | 537 | .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; } |
| 538 | .done-sec .done-body { transition: opacity .15s ease; } | 538 | .done-sec .done-body { transition: opacity .15s ease; } |
| 539 | .done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .35; } | 539 | .done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .35; } |
| 540 | +/* Sélecteur de langue (Réglages). */ | ||
| 541 | +.lang-pill { | ||
| 542 | + display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px; | ||
| 543 | + border-radius: 999px; cursor: pointer; font: inherit; font-size: 14px; | ||
| 544 | + border: 1px solid var(--color-divider); background: transparent; | ||
| 545 | + color: var(--color-text); min-height: 40px; | ||
| 546 | +} | ||
| 547 | +.lang-pill.current { | ||
| 548 | + background: var(--color-accent-2-200); color: var(--color-accent-2-900); | ||
| 549 | + border-color: var(--color-accent-2); | ||
| 550 | +} | ||
| 540 | /* Carte-lien « Mes saisies » (Réglages). */ | 551 | /* Carte-lien « Mes saisies » (Réglages). */ |
| 541 | a.times-link { flex-direction: row; align-items: center; text-decoration: none; color: var(--color-text); } | 552 | a.times-link { flex-direction: row; align-items: center; text-decoration: none; color: var(--color-text); } |
| 542 | 553 | ||
added
migrations/0007_user_lang.sql +6 -0 | new file mode 100644 | ||
| @@ -0,0 +1,6 @@ | ||
| 1 | +-- Langue de l'interface, PAR UTILISATEUR (jamais par navigateur) : le choix | |
| 2 | +-- suit le compte sur tous les appareils. Les comptes existants restent en | |
| 3 | +-- français ; à la création, l'application pose la langue par défaut | |
| 4 | +-- (variable d'environnement DEFAULT_LANG, fr sinon). | |
| 5 | +ALTER TABLE users ADD COLUMN lang TEXT NOT NULL DEFAULT 'fr' | |
| 6 | + CHECK (lang IN ('fr', 'en', 'de')); | |
| new file mode 100644 | |||
| @@ -0,0 +1,6 @@ | |||
| 1 | +-- Langue de l'interface, PAR UTILISATEUR (jamais par navigateur) : le choix | ||
| 2 | +-- suit le compte sur tous les appareils. Les comptes existants restent en | ||
| 3 | +-- français ; à la création, l'application pose la langue par défaut | ||
| 4 | +-- (variable d'environnement DEFAULT_LANG, fr sinon). | ||
| 5 | +ALTER TABLE users ADD COLUMN lang TEXT NOT NULL DEFAULT 'fr' | ||
| 6 | + CHECK (lang IN ('fr', 'en', 'de')); | ||
modified
src/charts.rs +5 -7 | @@ -4,6 +4,7 @@ use uuid::Uuid; | ||
| 4 | 4 | |
| 5 | 5 | use crate::db::stats::StatRow; |
| 6 | 6 | use crate::db::users::UserRow; |
| 7 | +use crate::i18n::Tr; | |
| 7 | 8 | |
| 8 | 9 | /// Palette du design system « Organic » — couleur par ordre de création de la |
| 9 | 10 | /// tâche (les UUIDv7 trient dans l'ordre de création), stable dans le temps. |
| @@ -99,6 +100,7 @@ pub fn build_chart( | ||
| 99 | 100 | rows: &[StatRow], |
| 100 | 101 | colors: &HashMap<Uuid, &'static str>, |
| 101 | 102 | nav: Option<ChartNav>, |
| 103 | + tr: &Tr, | |
| 102 | 104 | ) -> (ChartView, Vec<Sheet>) { |
| 103 | 105 | let mut by_user: BTreeMap<Uuid, Vec<&StatRow>> = BTreeMap::new(); |
| 104 | 106 | for r in rows { |
| @@ -140,11 +142,7 @@ pub fn build_chart( | ||
| 140 | 142 | duration: fmt_minutes(r.total), |
| 141 | 143 | share: format!("{share} %"), |
| 142 | 144 | period: range.to_string(), |
| 143 | - count: format!( | |
| 144 | - "{} saisie{}", | |
| 145 | - r.entries, | |
| 146 | - if r.entries > 1 { "s" } else { "" } | |
| 147 | - ), | |
| 145 | + count: tr.entries_count(r.entries), | |
| 148 | 146 | }); |
| 149 | 147 | segs.push(Seg { |
| 150 | 148 | flex: r.total, |
| @@ -236,7 +234,7 @@ mod tests { | ||
| 236 | 234 | row(1, 11, "réunions", 30, 1), |
| 237 | 235 | row(2, 10, "dev", 240, 3), |
| 238 | 236 | ]; |
| 239 | - let (chart, sheets) = build_chart("w", "t", "p", &users, &rows, &colors(), None); | |
| 237 | + let (chart, sheets) = build_chart("w", "t", "p", &users, &rows, &colors(), None, &crate::i18n::FR); | |
| 240 | 238 | assert_eq!(chart.rows.len(), 2); |
| 241 | 239 | assert_eq!(chart.rows[1].height_pct, 100); // bob = max |
| 242 | 240 | assert_eq!(chart.rows[0].height_pct, 50); // alice = 120/240 |
| @@ -253,7 +251,7 @@ mod tests { | ||
| 253 | 251 | fn zero_user_min_height() { |
| 254 | 252 | let users = [user(1, "alice"), user(2, "bob")]; |
| 255 | 253 | let rows = [row(1, 10, "dev", 60, 1)]; |
| 256 | - let (chart, _) = build_chart("w", "t", "p", &users, &rows, &colors(), None); | |
| 254 | + let (chart, _) = build_chart("w", "t", "p", &users, &rows, &colors(), None, &crate::i18n::FR); | |
| 257 | 255 | assert!(chart.rows[1].segs.is_empty()); |
| 258 | 256 | assert_eq!(chart.rows[1].height_pct, 2); |
| 259 | 257 | assert!(!chart.empty); |
| @@ -4,6 +4,7 @@ use uuid::Uuid; | |||
| 4 | 4 | ||
| 5 | use crate::db::stats::StatRow; | 5 | use crate::db::stats::StatRow; |
| 6 | use crate::db::users::UserRow; | 6 | use crate::db::users::UserRow; |
| 7 | +use crate::i18n::Tr; | ||
| 7 | 8 | ||
| 8 | /// Palette du design system « Organic » — couleur par ordre de création de la | 9 | /// Palette du design system « Organic » — couleur par ordre de création de la |
| 9 | /// tâche (les UUIDv7 trient dans l'ordre de création), stable dans le temps. | 10 | /// tâche (les UUIDv7 trient dans l'ordre de création), stable dans le temps. |
| @@ -99,6 +100,7 @@ pub fn build_chart( | |||
| 99 | rows: &[StatRow], | 100 | rows: &[StatRow], |
| 100 | colors: &HashMap<Uuid, &'static str>, | 101 | colors: &HashMap<Uuid, &'static str>, |
| 101 | nav: Option<ChartNav>, | 102 | nav: Option<ChartNav>, |
| 103 | + tr: &Tr, | ||
| 102 | ) -> (ChartView, Vec<Sheet>) { | 104 | ) -> (ChartView, Vec<Sheet>) { |
| 103 | let mut by_user: BTreeMap<Uuid, Vec<&StatRow>> = BTreeMap::new(); | 105 | let mut by_user: BTreeMap<Uuid, Vec<&StatRow>> = BTreeMap::new(); |
| 104 | for r in rows { | 106 | for r in rows { |
| @@ -140,11 +142,7 @@ pub fn build_chart( | |||
| 140 | duration: fmt_minutes(r.total), | 142 | duration: fmt_minutes(r.total), |
| 141 | share: format!("{share} %"), | 143 | share: format!("{share} %"), |
| 142 | period: range.to_string(), | 144 | period: range.to_string(), |
| 143 | - count: format!( | 145 | + count: tr.entries_count(r.entries), |
| 144 | - "{} saisie{}", | ||
| 145 | - r.entries, | ||
| 146 | - if r.entries > 1 { "s" } else { "" } | ||
| 147 | - ), | ||
| 148 | }); | 146 | }); |
| 149 | segs.push(Seg { | 147 | segs.push(Seg { |
| 150 | flex: r.total, | 148 | flex: r.total, |
| @@ -236,7 +234,7 @@ mod tests { | |||
| 236 | row(1, 11, "réunions", 30, 1), | 234 | row(1, 11, "réunions", 30, 1), |
| 237 | row(2, 10, "dev", 240, 3), | 235 | row(2, 10, "dev", 240, 3), |
| 238 | ]; | 236 | ]; |
| 239 | - let (chart, sheets) = build_chart("w", "t", "p", &users, &rows, &colors(), None); | 237 | + let (chart, sheets) = build_chart("w", "t", "p", &users, &rows, &colors(), None, &crate::i18n::FR); |
| 240 | assert_eq!(chart.rows.len(), 2); | 238 | assert_eq!(chart.rows.len(), 2); |
| 241 | assert_eq!(chart.rows[1].height_pct, 100); // bob = max | 239 | assert_eq!(chart.rows[1].height_pct, 100); // bob = max |
| 242 | assert_eq!(chart.rows[0].height_pct, 50); // alice = 120/240 | 240 | assert_eq!(chart.rows[0].height_pct, 50); // alice = 120/240 |
| @@ -253,7 +251,7 @@ mod tests { | |||
| 253 | fn zero_user_min_height() { | 251 | fn zero_user_min_height() { |
| 254 | let users = [user(1, "alice"), user(2, "bob")]; | 252 | let users = [user(1, "alice"), user(2, "bob")]; |
| 255 | let rows = [row(1, 10, "dev", 60, 1)]; | 253 | let rows = [row(1, 10, "dev", 60, 1)]; |
| 256 | - let (chart, _) = build_chart("w", "t", "p", &users, &rows, &colors(), None); | 254 | + let (chart, _) = build_chart("w", "t", "p", &users, &rows, &colors(), None, &crate::i18n::FR); |
| 257 | assert!(chart.rows[1].segs.is_empty()); | 255 | assert!(chart.rows[1].segs.is_empty()); |
| 258 | assert_eq!(chart.rows[1].height_pct, 2); | 256 | assert_eq!(chart.rows[1].height_pct, 2); |
| 259 | assert!(!chart.empty); | 257 | assert!(!chart.empty); |
modified
src/cli.rs +2 -1 | @@ -75,10 +75,11 @@ pub async fn user_add(username: &str, password_arg: Option<String>, team: &str) | ||
| 75 | 75 | let hash = password::hash(&password)?; |
| 76 | 76 | let id = Uuid::now_v7(); |
| 77 | 77 | let mut tx = pool.begin().await?; |
| 78 | - let created = sqlx::query("INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)") | |
| 78 | + let created = sqlx::query("INSERT INTO users (id, username, password_hash, lang) VALUES ($1, $2, $3, $4)") | |
| 79 | 79 | .bind(id) |
| 80 | 80 | .bind(username) |
| 81 | 81 | .bind(&hash) |
| 82 | + .bind(config::default_lang().code()) | |
| 82 | 83 | .execute(&mut *tx) |
| 83 | 84 | .await; |
| 84 | 85 | match created { |
| @@ -75,10 +75,11 @@ pub async fn user_add(username: &str, password_arg: Option<String>, team: &str) | |||
| 75 | let hash = password::hash(&password)?; | 75 | let hash = password::hash(&password)?; |
| 76 | let id = Uuid::now_v7(); | 76 | let id = Uuid::now_v7(); |
| 77 | let mut tx = pool.begin().await?; | 77 | let mut tx = pool.begin().await?; |
| 78 | - let created = sqlx::query("INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)") | 78 | + let created = sqlx::query("INSERT INTO users (id, username, password_hash, lang) VALUES ($1, $2, $3, $4)") |
| 79 | .bind(id) | 79 | .bind(id) |
| 80 | .bind(username) | 80 | .bind(username) |
| 81 | .bind(&hash) | 81 | .bind(&hash) |
| 82 | + .bind(config::default_lang().code()) | ||
| 82 | .execute(&mut *tx) | 83 | .execute(&mut *tx) |
| 83 | .await; | 84 | .await; |
| 84 | match created { | 85 | match created { |
modified
src/config.rs +9 -0 | @@ -25,6 +25,15 @@ pub fn port() -> u16 { | ||
| 25 | 25 | .unwrap_or(8080) |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | +/// Langue par défaut des nouveaux comptes et des pages anonymes | |
| 29 | +/// (login, erreurs). `DEFAULT_LANG` ∈ {fr, en, de}, fr sinon. | |
| 30 | +pub fn default_lang() -> crate::i18n::Lang { | |
| 31 | + std::env::var("DEFAULT_LANG") | |
| 32 | + .ok() | |
| 33 | + .and_then(|v| crate::i18n::Lang::from_code(v.trim())) | |
| 34 | + .unwrap_or(crate::i18n::Lang::Fr) | |
| 35 | +} | |
| 36 | + | |
| 28 | 37 | pub fn biscuit_private_key_hex() -> Result<String> { |
| 29 | 38 | std::env::var("BISCUIT_PRIVATE_KEY") |
| 30 | 39 | .context("BISCUIT_PRIVATE_KEY doit être définie (générer avec `time-time keygen`)") |
| @@ -25,6 +25,15 @@ pub fn port() -> u16 { | |||
| 25 | .unwrap_or(8080) | 25 | .unwrap_or(8080) |
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | +/// Langue par défaut des nouveaux comptes et des pages anonymes | ||
| 29 | +/// (login, erreurs). `DEFAULT_LANG` ∈ {fr, en, de}, fr sinon. | ||
| 30 | +pub fn default_lang() -> crate::i18n::Lang { | ||
| 31 | + std::env::var("DEFAULT_LANG") | ||
| 32 | + .ok() | ||
| 33 | + .and_then(|v| crate::i18n::Lang::from_code(v.trim())) | ||
| 34 | + .unwrap_or(crate::i18n::Lang::Fr) | ||
| 35 | +} | ||
| 36 | + | ||
| 28 | pub fn biscuit_private_key_hex() -> Result<String> { | 37 | pub fn biscuit_private_key_hex() -> Result<String> { |
| 29 | std::env::var("BISCUIT_PRIVATE_KEY") | 38 | std::env::var("BISCUIT_PRIVATE_KEY") |
| 30 | .context("BISCUIT_PRIVATE_KEY doit être définie (générer avec `time-time keygen`)") | 39 | .context("BISCUIT_PRIVATE_KEY doit être définie (générer avec `time-time keygen`)") |
modified
src/db/users.rs +18 -0 | @@ -45,6 +45,24 @@ pub async fn username(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<String>> { | ||
| 45 | 45 | Ok(row.map(|(u,)| u)) |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | +/// (username, code langue) — profil minimal chargé à chaque requête. | |
| 49 | +pub async fn profile(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<(String, String)>> { | |
| 50 | + sqlx::query_as("SELECT username, lang FROM users WHERE id = $1") | |
| 51 | + .bind(id) | |
| 52 | + .fetch_optional(pool) | |
| 53 | + .await | |
| 54 | +} | |
| 55 | + | |
| 56 | +/// Le CHECK en base garde le garde-fou final sur les codes valides. | |
| 57 | +pub async fn set_lang(pool: &PgPool, id: Uuid, lang: &str) -> sqlx::Result<()> { | |
| 58 | + sqlx::query("UPDATE users SET lang = $1 WHERE id = $2") | |
| 59 | + .bind(lang) | |
| 60 | + .bind(id) | |
| 61 | + .execute(pool) | |
| 62 | + .await?; | |
| 63 | + Ok(()) | |
| 64 | +} | |
| 65 | + | |
| 48 | 66 | /// Cible d'une invitation. |
| 49 | 67 | pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> { |
| 50 | 68 | sqlx::query_as("SELECT id, username FROM users WHERE username = $1") |
| @@ -45,6 +45,24 @@ pub async fn username(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<String>> { | |||
| 45 | Ok(row.map(|(u,)| u)) | 45 | Ok(row.map(|(u,)| u)) |
| 46 | } | 46 | } |
| 47 | 47 | ||
| 48 | +/// (username, code langue) — profil minimal chargé à chaque requête. | ||
| 49 | +pub async fn profile(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<(String, String)>> { | ||
| 50 | + sqlx::query_as("SELECT username, lang FROM users WHERE id = $1") | ||
| 51 | + .bind(id) | ||
| 52 | + .fetch_optional(pool) | ||
| 53 | + .await | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +/// Le CHECK en base garde le garde-fou final sur les codes valides. | ||
| 57 | +pub async fn set_lang(pool: &PgPool, id: Uuid, lang: &str) -> sqlx::Result<()> { | ||
| 58 | + sqlx::query("UPDATE users SET lang = $1 WHERE id = $2") | ||
| 59 | + .bind(lang) | ||
| 60 | + .bind(id) | ||
| 61 | + .execute(pool) | ||
| 62 | + .await?; | ||
| 63 | + Ok(()) | ||
| 64 | +} | ||
| 65 | + | ||
| 48 | /// Cible d'une invitation. | 66 | /// Cible d'une invitation. |
| 49 | pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> { | 67 | pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> { |
| 50 | sqlx::query_as("SELECT id, username FROM users WHERE username = $1") | 68 | sqlx::query_as("SELECT id, username FROM users WHERE username = $1") |
modified
src/error.rs +9 -11 | @@ -19,27 +19,25 @@ pub enum AppError { | ||
| 19 | 19 | |
| 20 | 20 | impl IntoResponse for AppError { |
| 21 | 21 | fn into_response(self) -> Response { |
| 22 | + let lang = crate::config::default_lang(); | |
| 23 | + let tr = lang.tr(); | |
| 22 | 24 | let (status, message) = match &self { |
| 23 | - AppError::NotFound => (StatusCode::NOT_FOUND, "Page introuvable.".to_string()), | |
| 25 | + AppError::NotFound => (StatusCode::NOT_FOUND, tr.err_404.to_string()), | |
| 24 | 26 | AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()), |
| 25 | - AppError::Csrf => ( | |
| 26 | - StatusCode::FORBIDDEN, | |
| 27 | - "Formulaire expiré, veuillez réessayer.".to_string(), | |
| 28 | - ), | |
| 27 | + AppError::Csrf => (StatusCode::FORBIDDEN, tr.err_csrf.to_string()), | |
| 29 | 28 | _ => { |
| 30 | 29 | tracing::error!(error = %self, "erreur interne"); |
| 31 | - ( | |
| 32 | - StatusCode::INTERNAL_SERVER_ERROR, | |
| 33 | - "Erreur interne.".to_string(), | |
| 34 | - ) | |
| 30 | + (StatusCode::INTERNAL_SERVER_ERROR, tr.err_500.to_string()) | |
| 35 | 31 | } |
| 36 | 32 | }; |
| 37 | 33 | let body = format!( |
| 38 | - r#"<!doctype html><html lang="fr"><head><meta charset="utf-8"> | |
| 34 | + r#"<!doctype html><html lang="{lang}"><head><meta charset="utf-8"> | |
| 39 | 35 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 40 | 36 | <link rel="stylesheet" href="/static/style.css"><title>{status}</title></head> |
| 41 | 37 | <body><main class="page"><h1>{code}</h1><p>{message}</p> |
| 42 | -<p><a class="btn" href="/">← Accueil</a></p></main></body></html>"#, | |
| 38 | +<p><a class="btn" href="/">{back}</a></p></main></body></html>"#, | |
| 39 | + lang = lang.code(), | |
| 40 | + back = tr.back_home, | |
| 43 | 41 | status = status.as_u16(), |
| 44 | 42 | code = status.as_u16(), |
| 45 | 43 | ); |
| @@ -19,27 +19,25 @@ pub enum AppError { | |||
| 19 | 19 | ||
| 20 | impl IntoResponse for AppError { | 20 | impl IntoResponse for AppError { |
| 21 | fn into_response(self) -> Response { | 21 | fn into_response(self) -> Response { |
| 22 | + let lang = crate::config::default_lang(); | ||
| 23 | + let tr = lang.tr(); | ||
| 22 | let (status, message) = match &self { | 24 | let (status, message) = match &self { |
| 23 | - AppError::NotFound => (StatusCode::NOT_FOUND, "Page introuvable.".to_string()), | 25 | + AppError::NotFound => (StatusCode::NOT_FOUND, tr.err_404.to_string()), |
| 24 | AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()), | 26 | AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()), |
| 25 | - AppError::Csrf => ( | 27 | + AppError::Csrf => (StatusCode::FORBIDDEN, tr.err_csrf.to_string()), |
| 26 | - StatusCode::FORBIDDEN, | ||
| 27 | - "Formulaire expiré, veuillez réessayer.".to_string(), | ||
| 28 | - ), | ||
| 29 | _ => { | 28 | _ => { |
| 30 | tracing::error!(error = %self, "erreur interne"); | 29 | tracing::error!(error = %self, "erreur interne"); |
| 31 | - ( | 30 | + (StatusCode::INTERNAL_SERVER_ERROR, tr.err_500.to_string()) |
| 32 | - StatusCode::INTERNAL_SERVER_ERROR, | ||
| 33 | - "Erreur interne.".to_string(), | ||
| 34 | - ) | ||
| 35 | } | 31 | } |
| 36 | }; | 32 | }; |
| 37 | let body = format!( | 33 | let body = format!( |
| 38 | - r#"<!doctype html><html lang="fr"><head><meta charset="utf-8"> | 34 | + r#"<!doctype html><html lang="{lang}"><head><meta charset="utf-8"> |
| 39 | <meta name="viewport" content="width=device-width, initial-scale=1"> | 35 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 40 | <link rel="stylesheet" href="/static/style.css"><title>{status}</title></head> | 36 | <link rel="stylesheet" href="/static/style.css"><title>{status}</title></head> |
| 41 | <body><main class="page"><h1>{code}</h1><p>{message}</p> | 37 | <body><main class="page"><h1>{code}</h1><p>{message}</p> |
| 42 | -<p><a class="btn" href="/">← Accueil</a></p></main></body></html>"#, | 38 | +<p><a class="btn" href="/">{back}</a></p></main></body></html>"#, |
| 39 | + lang = lang.code(), | ||
| 40 | + back = tr.back_home, | ||
| 43 | status = status.as_u16(), | 41 | status = status.as_u16(), |
| 44 | code = status.as_u16(), | 42 | code = status.as_u16(), |
| 45 | ); | 43 | ); |
added
src/i18n.rs +954 -0 | new file mode 100644 | ||
| @@ -0,0 +1,954 @@ | ||
| 1 | +//! Traductions FR / EN / DE. La langue est PAR UTILISATEUR, stockée en base | |
| 2 | +//! (`users.lang`) — jamais par navigateur. Défaut à la création d'un compte : | |
| 3 | +//! `DEFAULT_LANG` (fr sinon). Les pages anonymes (login, erreurs) utilisent | |
| 4 | +//! la langue par défaut. | |
| 5 | +//! | |
| 6 | +//! Principe : `Tr` est une table de chaînes statiques, une instance par | |
| 7 | +//! langue. Les templates lisent `shell.tr.*` ; les chaînes à interpolation | |
| 8 | +//! (motifs `{}`) sont résolues côté Rust avec `p1`/`p2`. | |
| 9 | + | |
| 10 | +#[derive(Clone, Copy, PartialEq, Eq, Debug)] | |
| 11 | +pub enum Lang { | |
| 12 | + Fr, | |
| 13 | + En, | |
| 14 | + De, | |
| 15 | +} | |
| 16 | + | |
| 17 | +impl Lang { | |
| 18 | + pub fn from_code(code: &str) -> Option<Lang> { | |
| 19 | + match code { | |
| 20 | + "fr" => Some(Lang::Fr), | |
| 21 | + "en" => Some(Lang::En), | |
| 22 | + "de" => Some(Lang::De), | |
| 23 | + _ => None, | |
| 24 | + } | |
| 25 | + } | |
| 26 | + | |
| 27 | + pub fn code(self) -> &'static str { | |
| 28 | + match self { | |
| 29 | + Lang::Fr => "fr", | |
| 30 | + Lang::En => "en", | |
| 31 | + Lang::De => "de", | |
| 32 | + } | |
| 33 | + } | |
| 34 | + | |
| 35 | + pub fn tr(self) -> &'static Tr { | |
| 36 | + match self { | |
| 37 | + Lang::Fr => &FR, | |
| 38 | + Lang::En => &EN, | |
| 39 | + Lang::De => &DE, | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + /// Nom de la langue, dans cette langue (libellés du sélecteur). | |
| 44 | + pub fn self_name(self) -> &'static str { | |
| 45 | + match self { | |
| 46 | + Lang::Fr => "Français", | |
| 47 | + Lang::En => "English", | |
| 48 | + Lang::De => "Deutsch", | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + pub const ALL: [Lang; 3] = [Lang::Fr, Lang::En, Lang::De]; | |
| 53 | +} | |
| 54 | + | |
| 55 | +/// Interpolation à un trou (motifs `{}` des champs `*_pat`). | |
| 56 | +pub fn p1(pat: &str, a: impl std::fmt::Display) -> String { | |
| 57 | + pat.replacen("{}", &a.to_string(), 1) | |
| 58 | +} | |
| 59 | + | |
| 60 | +/// Interpolation à deux trous, dans l'ordre. | |
| 61 | +pub fn p2(pat: &str, a: impl std::fmt::Display, b: impl std::fmt::Display) -> String { | |
| 62 | + p1(&p1(pat, a), b) | |
| 63 | +} | |
| 64 | + | |
| 65 | +pub struct Tr { | |
| 66 | + // ─── shell : tab bar, pill, sheets, timer ─── | |
| 67 | + pub tab_add: &'static str, | |
| 68 | + pub tab_stats: &'static str, | |
| 69 | + pub tab_todos: &'static str, | |
| 70 | + pub tab_settings: &'static str, | |
| 71 | + pub change_team: &'static str, | |
| 72 | + pub close: &'static str, | |
| 73 | + pub member_one: &'static str, | |
| 74 | + pub member_many: &'static str, | |
| 75 | + pub start_timer: &'static str, | |
| 76 | + pub start_timer_aria: &'static str, | |
| 77 | + pub stop_timer_aria: &'static str, | |
| 78 | + pub timer_running_note: &'static str, | |
| 79 | + pub record_measured_aria: &'static str, | |
| 80 | + pub record_measured_switch_aria: &'static str, | |
| 81 | + pub measured_pat: &'static str, | |
| 82 | + pub in_team_pat: &'static str, | |
| 83 | + pub timer_in_team_pat: &'static str, | |
| 84 | + // ─── login ─── | |
| 85 | + pub login_title: &'static str, | |
| 86 | + pub tagline: &'static str, | |
| 87 | + pub username_label: &'static str, | |
| 88 | + pub password_label: &'static str, | |
| 89 | + pub sign_in: &'static str, | |
| 90 | + pub bad_credentials: &'static str, | |
| 91 | + // ─── ajout ─── | |
| 92 | + pub title_add: &'static str, | |
| 93 | + pub step1_hint: &'static str, | |
| 94 | + pub no_categories_yet: &'static str, | |
| 95 | + pub manage_categories: &'static str, | |
| 96 | + pub change_category: &'static str, | |
| 97 | + pub duration_label: &'static str, | |
| 98 | + pub minutes_unit: &'static str, | |
| 99 | + pub date_label: &'static str, | |
| 100 | + pub five_less: &'static str, | |
| 101 | + pub five_more: &'static str, | |
| 102 | + pub day_prev: &'static str, | |
| 103 | + pub day_next: &'static str, | |
| 104 | + pub forgot_timer: &'static str, | |
| 105 | + pub cancel_measured: &'static str, | |
| 106 | + pub measured_pick_pat: &'static str, | |
| 107 | + pub week_hours_pat: &'static str, | |
| 108 | + pub added_pat: &'static str, | |
| 109 | + pub err_duration_min: &'static str, | |
| 110 | + pub err_cat_team: &'static str, | |
| 111 | + // ─── minuteur ─── | |
| 112 | + pub title_timer: &'static str, | |
| 113 | + pub chrono_kicker: &'static str, | |
| 114 | + pub started_at_pat: &'static str, | |
| 115 | + pub chrono_hint: &'static str, | |
| 116 | + pub finish_timer: &'static str, | |
| 117 | + pub cancel_timer: &'static str, | |
| 118 | + pub toast_measure_discarded: &'static str, | |
| 119 | + pub toast_timer_cancelled: &'static str, | |
| 120 | + // ─── stats ─── | |
| 121 | + pub title_stats: &'static str, | |
| 122 | + pub my_entries_arrow: &'static str, | |
| 123 | + pub this_week: &'static str, | |
| 124 | + pub this_month: &'static str, | |
| 125 | + pub this_year: &'static str, | |
| 126 | + pub all_time: &'static str, | |
| 127 | + pub no_time_period: &'static str, | |
| 128 | + pub all_history: &'static str, | |
| 129 | + pub prev_period: &'static str, | |
| 130 | + pub next_period: &'static str, | |
| 131 | + pub share_of_time: &'static str, | |
| 132 | + pub detail: &'static str, | |
| 133 | + pub entry_one: &'static str, | |
| 134 | + pub entry_many: &'static str, | |
| 135 | + // ─── mes temps / édition ─── | |
| 136 | + pub title_times: &'static str, | |
| 137 | + pub times_empty: &'static str, | |
| 138 | + pub see_more: &'static str, | |
| 139 | + pub edit_aria: &'static str, | |
| 140 | + pub title_edit: &'static str, | |
| 141 | + pub back_times: &'static str, | |
| 142 | + pub category_label: &'static str, | |
| 143 | + pub duration_min_label: &'static str, | |
| 144 | + pub save: &'static str, | |
| 145 | + pub delete: &'static str, | |
| 146 | + pub archived_suffix_pat: &'static str, | |
| 147 | + pub toast_time_edited: &'static str, | |
| 148 | + pub toast_time_deleted: &'static str, | |
| 149 | + // ─── catégories ─── | |
| 150 | + pub title_categories: &'static str, | |
| 151 | + pub categories_hint_pat: &'static str, | |
| 152 | + pub new_category_ph: &'static str, | |
| 153 | + pub add: &'static str, | |
| 154 | + pub rename: &'static str, | |
| 155 | + pub rename_cat_aria: &'static str, | |
| 156 | + pub restore: &'static str, | |
| 157 | + pub archive_aria: &'static str, | |
| 158 | + pub rename_help: &'static str, | |
| 159 | + pub back_add: &'static str, | |
| 160 | + pub toast_cat_added: &'static str, | |
| 161 | + pub toast_cat_renamed: &'static str, | |
| 162 | + pub toast_cat_archived: &'static str, | |
| 163 | + pub toast_cat_restored: &'static str, | |
| 164 | + pub err_cat_dup: &'static str, | |
| 165 | + pub err_cat_empty: &'static str, | |
| 166 | + // ─── réglages ─── | |
| 167 | + pub title_settings: &'static str, | |
| 168 | + pub invitation_kicker: &'static str, | |
| 169 | + pub invite_text_pat: &'static str, | |
| 170 | + pub accept: &'static str, | |
| 171 | + pub refuse: &'static str, | |
| 172 | + pub account_kicker: &'static str, | |
| 173 | + pub account_line_pat: &'static str, | |
| 174 | + pub account_line_single: &'static str, | |
| 175 | + pub my_entries: &'static str, | |
| 176 | + pub my_entries_desc: &'static str, | |
| 177 | + pub team_name_label: &'static str, | |
| 178 | + pub rename_team_note: &'static str, | |
| 179 | + pub members_of_pat: &'static str, | |
| 180 | + pub invite_user_label: &'static str, | |
| 181 | + pub username_ph: &'static str, | |
| 182 | + pub invite: &'static str, | |
| 183 | + pub invite_note: &'static str, | |
| 184 | + pub pending_invites: &'static str, | |
| 185 | + pub invited_by_pat: &'static str, | |
| 186 | + pub cancel: &'static str, | |
| 187 | + pub create_team: &'static str, | |
| 188 | + pub create: &'static str, | |
| 189 | + pub new_team_aria: &'static str, | |
| 190 | + pub create_team_note: &'static str, | |
| 191 | + pub leave_team_pat: &'static str, | |
| 192 | + pub leave_note: &'static str, | |
| 193 | + pub language: &'static str, | |
| 194 | + pub session: &'static str, | |
| 195 | + pub current_password: &'static str, | |
| 196 | + pub new_password: &'static str, | |
| 197 | + pub confirmation: &'static str, | |
| 198 | + pub change_password: &'static str, | |
| 199 | + pub sign_out: &'static str, | |
| 200 | + pub signout_note: &'static str, | |
| 201 | + pub toast_lang_updated: &'static str, | |
| 202 | + pub toast_password_updated: &'static str, | |
| 203 | + pub toast_invite_sent: &'static str, | |
| 204 | + pub toast_invite_refused: &'static str, | |
| 205 | + pub toast_invite_cancelled: &'static str, | |
| 206 | + pub team_created_pat: &'static str, | |
| 207 | + pub toast_team_renamed: &'static str, | |
| 208 | + pub toast_joined_pat: &'static str, | |
| 209 | + pub toast_team_left: &'static str, | |
| 210 | + pub err_team_name_taken: &'static str, | |
| 211 | + pub err_team_name_empty: &'static str, | |
| 212 | + pub err_last_team: &'static str, | |
| 213 | + pub err_password_short: &'static str, | |
| 214 | + pub err_password_mismatch: &'static str, | |
| 215 | + pub err_password_wrong: &'static str, | |
| 216 | + pub invite_unknown_pat: &'static str, | |
| 217 | + pub already_member_pat: &'static str, | |
| 218 | + pub already_invited_pat: &'static str, | |
| 219 | + // ─── todos ─── | |
| 220 | + pub title_todos: &'static str, | |
| 221 | + pub new_todo: &'static str, | |
| 222 | + pub todos_empty_title: &'static str, | |
| 223 | + pub todos_empty_body_pat: &'static str, | |
| 224 | + pub doing_block: &'static str, | |
| 225 | + pub todo_block: &'static str, | |
| 226 | + pub my_tasks: &'static str, | |
| 227 | + pub doing_filter_empty: &'static str, | |
| 228 | + pub open_filter_empty: &'static str, | |
| 229 | + pub upcoming: &'static str, | |
| 230 | + pub take: &'static str, | |
| 231 | + pub done_check: &'static str, | |
| 232 | + pub release: &'static str, | |
| 233 | + pub edit_todo_aria_pat: &'static str, | |
| 234 | + pub taken_by_pat: &'static str, | |
| 235 | + pub since_pat: &'static str, | |
| 236 | + pub activated_today: &'static str, | |
| 237 | + pub from_pat: &'static str, | |
| 238 | + pub back_todos: &'static str, | |
| 239 | + pub title_new_todo: &'static str, | |
| 240 | + pub title_edit_todo: &'static str, | |
| 241 | + pub todo_name_label: &'static str, | |
| 242 | + pub todo_name_ph: &'static str, | |
| 243 | + pub activation_date: &'static str, | |
| 244 | + pub assign_to: &'static str, | |
| 245 | + pub assign_note: &'static str, | |
| 246 | + pub you_suffix: &'static str, | |
| 247 | + pub create_todo: &'static str, | |
| 248 | + pub delete_todo: &'static str, | |
| 249 | + pub err_todo_incomplete: &'static str, | |
| 250 | + pub title_todo_done: &'static str, | |
| 251 | + pub duration_spent: &'static str, | |
| 252 | + pub record_time: &'static str, | |
| 253 | + pub time_date: &'static str, | |
| 254 | + pub reschedule: &'static str, | |
| 255 | + pub next_activation_aria: &'static str, | |
| 256 | + pub minutes_spent_aria: &'static str, | |
| 257 | + pub validate: &'static str, | |
| 258 | + pub rhythm_every_pat: &'static str, | |
| 259 | + pub rhythm_every_one: &'static str, | |
| 260 | + pub rhythm_none_pat: &'static str, | |
| 261 | + pub toast_todo_created: &'static str, | |
| 262 | + pub toast_todo_edited: &'static str, | |
| 263 | + pub toast_todo_deleted: &'static str, | |
| 264 | + pub toast_todo_taken: &'static str, | |
| 265 | + pub toast_todo_released: &'static str, | |
| 266 | + pub toast_todo_done: &'static str, | |
| 267 | + pub toast_todo_done_reprog: &'static str, | |
| 268 | + // ─── erreurs ─── | |
| 269 | + pub err_404: &'static str, | |
| 270 | + pub err_csrf: &'static str, | |
| 271 | + pub err_500: &'static str, | |
| 272 | + pub back_home: &'static str, | |
| 273 | + // ─── dates ─── | |
| 274 | + pub weekdays: [&'static str; 7], | |
| 275 | + pub months: [&'static str; 12], | |
| 276 | + pub today_suffix: &'static str, | |
| 277 | + pub week_range_pat: &'static str, | |
| 278 | + pub year_pat: &'static str, | |
| 279 | +} | |
| 280 | + | |
| 281 | +impl Tr { | |
| 282 | + /// « 3 membres » / "3 members" / „3 Mitglieder“. | |
| 283 | + pub fn members_count(&self, n: i64) -> String { | |
| 284 | + format!("{n} {}", if n > 1 { self.member_many } else { self.member_one }) | |
| 285 | + } | |
| 286 | + | |
| 287 | + /// « 3 saisies » / "3 entries" / „3 Einträge“. | |
| 288 | + pub fn entries_count(&self, n: i64) -> String { | |
| 289 | + format!("{n} {}", if n > 1 { self.entry_many } else { self.entry_one }) | |
| 290 | + } | |
| 291 | + | |
| 292 | + pub fn account_line(&self, teams: i64) -> String { | |
| 293 | + if teams > 1 { | |
| 294 | + p1(self.account_line_pat, teams) | |
| 295 | + } else { | |
| 296 | + self.account_line_single.to_string() | |
| 297 | + } | |
| 298 | + } | |
| 299 | + | |
| 300 | + pub fn rhythm_every(&self, days: i64) -> String { | |
| 301 | + if days == 1 { | |
| 302 | + self.rhythm_every_one.to_string() | |
| 303 | + } else { | |
| 304 | + p1(self.rhythm_every_pat, days) | |
| 305 | + } | |
| 306 | + } | |
| 307 | +} | |
| 308 | + | |
| 309 | +pub static FR: Tr = Tr { | |
| 310 | + tab_add: "Ajouter", | |
| 311 | + tab_stats: "Stats", | |
| 312 | + tab_todos: "Tâches", | |
| 313 | + tab_settings: "Réglages", | |
| 314 | + change_team: "Changer d'équipe", | |
| 315 | + close: "Fermer", | |
| 316 | + member_one: "membre", | |
| 317 | + member_many: "membres", | |
| 318 | + start_timer: "Lancer le minuteur", | |
| 319 | + start_timer_aria: "Démarrer le timer", | |
| 320 | + stop_timer_aria: "Arrêter le timer", | |
| 321 | + timer_running_note: "en cours", | |
| 322 | + record_measured_aria: "Enregistrer le temps mesuré", | |
| 323 | + record_measured_switch_aria: "Enregistrer le temps mesuré (bascule d'équipe)", | |
| 324 | + measured_pat: "{} mesurées", | |
| 325 | + in_team_pat: "dans l'équipe {}", | |
| 326 | + timer_in_team_pat: "timer dans l'équipe {}", | |
| 327 | + login_title: "Connexion", | |
| 328 | + tagline: "Le temps passé sur chaque catégorie, partagé avec ton équipe.", | |
| 329 | + username_label: "Identifiant", | |
| 330 | + password_label: "Mot de passe", | |
| 331 | + sign_in: "Se connecter", | |
| 332 | + bad_credentials: "Identifiants incorrects.", | |
| 333 | + title_add: "Ajouter du temps", | |
| 334 | + step1_hint: "Étape 1 sur 2 — choisis la catégorie", | |
| 335 | + no_categories_yet: "Aucune catégorie pour l'instant — commence par en créer une.", | |
| 336 | + manage_categories: "Gérer les catégories", | |
| 337 | + change_category: "← Changer de catégorie", | |
| 338 | + duration_label: "Durée", | |
| 339 | + minutes_unit: "minutes", | |
| 340 | + date_label: "Date", | |
| 341 | + five_less: "Cinq minutes de moins", | |
| 342 | + five_more: "Cinq minutes de plus", | |
| 343 | + day_prev: "Jour précédent", | |
| 344 | + day_next: "Jour suivant", | |
| 345 | + forgot_timer: "Timer oublié ? Tu pourras corriger la durée à l'étape suivante.", | |
| 346 | + cancel_measured: "Annuler ce temps", | |
| 347 | + measured_pick_pat: "{} mesurées — choisis la catégorie", | |
| 348 | + week_hours_pat: "{} cette semaine", | |
| 349 | + added_pat: "{} ajouté ✓", | |
| 350 | + err_duration_min: "La durée doit être d'au moins 1 minute.", | |
| 351 | + err_cat_team: "Cette catégorie n'appartient pas à l'équipe active.", | |
| 352 | + title_timer: "Minuteur", | |
| 353 | + chrono_kicker: "Minuteur en cours", | |
| 354 | + started_at_pat: "Démarré à {}", | |
| 355 | + chrono_hint: "Termine le minuteur pour choisir la catégorie et enregistrer ce temps.", | |
| 356 | + finish_timer: "Finir le minuteur", | |
| 357 | + cancel_timer: "Annuler le minuteur", | |
| 358 | + toast_measure_discarded: "Temps mesuré annulé", | |
| 359 | + toast_timer_cancelled: "Minuteur annulé", | |
| 360 | + title_stats: "Statistiques", | |
| 361 | + my_entries_arrow: "Mes saisies →", | |
| 362 | + this_week: "Cette semaine", | |
| 363 | + this_month: "Ce mois-ci", | |
| 364 | + this_year: "Cette année", | |
| 365 | + all_time: "Depuis le début", | |
| 366 | + no_time_period: "Aucun temps enregistré sur cette période.", | |
| 367 | + all_history: "Tout l'historique", | |
| 368 | + prev_period: "Période précédente", | |
| 369 | + next_period: "Période suivante", | |
| 370 | + share_of_time: "Part de son temps", | |
| 371 | + detail: "Détail", | |
| 372 | + entry_one: "saisie", | |
| 373 | + entry_many: "saisies", | |
| 374 | + title_times: "Mes temps", | |
| 375 | + times_empty: "Tu n'as encore enregistré aucun temps dans cette équipe.", | |
| 376 | + see_more: "Voir plus", | |
| 377 | + edit_aria: "Modifier", | |
| 378 | + title_edit: "Modifier le temps", | |
| 379 | + back_times: "← Mes temps", | |
| 380 | + category_label: "Catégorie", | |
| 381 | + duration_min_label: "Durée (min)", | |
| 382 | + save: "Enregistrer", | |
| 383 | + delete: "Supprimer", | |
| 384 | + archived_suffix_pat: "{} (archivée)", | |
| 385 | + toast_time_edited: "Temps modifié", | |
| 386 | + toast_time_deleted: "Temps supprimé", | |
| 387 | + title_categories: "Catégories", | |
| 388 | + categories_hint_pat: "Les catégories appartiennent à l'équipe {} et sont modifiables par tous ses membres. Archiver une catégorie la retire des listes ; les temps déjà saisis restent dans les stats.", | |
| 389 | + new_category_ph: "Nouvelle catégorie", | |
| 390 | + add: "Ajouter", | |
| 391 | + rename: "Renommer", | |
| 392 | + rename_cat_aria: "Renommer la catégorie (Entrée pour valider)", | |
| 393 | + restore: "Restaurer", | |
| 394 | + archive_aria: "Archiver", | |
| 395 | + rename_help: "Renommer : modifie le nom puis appuie sur Entrée.", | |
| 396 | + back_add: "← Retour à l'ajout", | |
| 397 | + toast_cat_added: "Catégorie ajoutée", | |
| 398 | + toast_cat_renamed: "Catégorie renommée", | |
| 399 | + toast_cat_archived: "Catégorie archivée — les temps restent dans les stats", | |
| 400 | + toast_cat_restored: "Catégorie restaurée", | |
| 401 | + err_cat_dup: "Une catégorie porte déjà ce nom", | |
| 402 | + err_cat_empty: "Le nom ne peut pas être vide", | |
| 403 | + title_settings: "Paramètres", | |
| 404 | + invitation_kicker: "Invitation", | |
| 405 | + invite_text_pat: "{} t’invite à rejoindre l’équipe « {} ».", | |
| 406 | + accept: "Accepter", | |
| 407 | + refuse: "Refuser", | |
| 408 | + account_kicker: "Compte", | |
| 409 | + account_line_pat: "Membre de {} équipes. Les comptes sont créés en ligne de commande.", | |
| 410 | + account_line_single: "Les comptes sont créés en ligne de commande.", | |
| 411 | + my_entries: "Mes saisies", | |
| 412 | + my_entries_desc: "Consulter et corriger les temps que j'ai enregistrés.", | |
| 413 | + team_name_label: "Nom de l'équipe", | |
| 414 | + rename_team_note: "Le nouveau nom est visible par tous les membres.", | |
| 415 | + members_of_pat: "Membres de {}", | |
| 416 | + invite_user_label: "Inviter un utilisateur", | |
| 417 | + username_ph: "identifiant", | |
| 418 | + invite: "Inviter", | |
| 419 | + invite_note: "L'utilisateur doit déjà avoir un compte. Il reçoit une invitation à accepter de son côté.", | |
| 420 | + pending_invites: "Invitations en attente", | |
| 421 | + invited_by_pat: "invité par {} · en attente", | |
| 422 | + cancel: "Annuler", | |
| 423 | + create_team: "Créer une équipe", | |
| 424 | + create: "Créer", | |
| 425 | + new_team_aria: "Nom de la nouvelle équipe", | |
| 426 | + create_team_note: "Tu bascules dessus aussitôt — invite ensuite qui tu veux.", | |
| 427 | + leave_team_pat: "Quitter « {} »", | |
| 428 | + leave_note: "Tes temps déjà saisis restent dans les stats de l'équipe.", | |
| 429 | + language: "Langue", | |
| 430 | + session: "Session", | |
| 431 | + current_password: "Mot de passe actuel", | |
| 432 | + new_password: "Nouveau mot de passe", | |
| 433 | + confirmation: "Confirmation", | |
| 434 | + change_password: "Changer le mot de passe", | |
| 435 | + sign_out: "Se déconnecter", | |
| 436 | + signout_note: "Le biscuit de session sera révoqué.", | |
| 437 | + toast_lang_updated: "Langue mise à jour", | |
| 438 | + toast_password_updated: "Mot de passe mis à jour", | |
| 439 | + toast_invite_sent: "Invitation envoyée", | |
| 440 | + toast_invite_refused: "Invitation refusée", | |
| 441 | + toast_invite_cancelled: "Invitation annulée", | |
| 442 | + team_created_pat: "Équipe {} créée", | |
| 443 | + toast_team_renamed: "Équipe renommée", | |
| 444 | + toast_joined_pat: "Tu as rejoint {}", | |
| 445 | + toast_team_left: "Équipe quittée", | |
| 446 | + err_team_name_taken: "Ce nom d'équipe est déjà pris", | |
| 447 | + err_team_name_empty: "Le nom d'équipe ne peut pas être vide", | |
| 448 | + err_last_team: "Impossible de quitter ta dernière équipe", | |
| 449 | + err_password_short: "Le nouveau mot de passe doit faire au moins 8 caractères.", | |
| 450 | + err_password_mismatch: "La confirmation ne correspond pas.", | |
| 451 | + err_password_wrong: "Mot de passe actuel incorrect.", | |
| 452 | + invite_unknown_pat: "Aucun compte « {} ».", | |
| 453 | + already_member_pat: "{} fait déjà partie de l'équipe.", | |
| 454 | + already_invited_pat: "Une invitation est déjà en attente pour {}.", | |
| 455 | + title_todos: "Tâches", | |
| 456 | + new_todo: "+ Nouvelle tâche", | |
| 457 | + todos_empty_title: "Aucune tâche pour l'instant", | |
| 458 | + todos_empty_body_pat: "Ajoute ce qu'il y a à faire dans {} : chacun pourra la prendre, la faire, et le temps passé ira dans les stats.", | |
| 459 | + doing_block: "En cours", | |
| 460 | + todo_block: "À faire", | |
| 461 | + my_tasks: "Mes tâches", | |
| 462 | + doing_filter_empty: "Aucune tâche en cours ne t'est assignée.", | |
| 463 | + open_filter_empty: "Aucune tâche à faire ne t'est assignée.", | |
| 464 | + upcoming: "À venir", | |
| 465 | + take: "Prendre", | |
| 466 | + done_check: "Fait ✓", | |
| 467 | + release: "Relâcher", | |
| 468 | + edit_todo_aria_pat: "Modifier la tâche {}", | |
| 469 | + taken_by_pat: "pris par {} · {}", | |
| 470 | + since_pat: "depuis le {}", | |
| 471 | + activated_today: "activée aujourd'hui", | |
| 472 | + from_pat: "à partir du {}", | |
| 473 | + back_todos: "← Tâches", | |
| 474 | + title_new_todo: "Nouvelle tâche", | |
| 475 | + title_edit_todo: "Modifier la tâche", | |
| 476 | + todo_name_label: "Nom de la tâche", | |
| 477 | + todo_name_ph: "Nettoyer la salle de bain", | |
| 478 | + activation_date: "Date d'activation", | |
| 479 | + assign_to: "Assigner à", | |
| 480 | + assign_note: "Facultatif. Les autres membres peuvent quand même prendre la tâche.", | |
| 481 | + you_suffix: " (toi)", | |
| 482 | + create_todo: "Créer la tâche", | |
| 483 | + delete_todo: "Supprimer la tâche", | |
| 484 | + err_todo_incomplete: "Nom, catégorie et date sont requis.", | |
| 485 | + title_todo_done: "Tâche effectuée", | |
| 486 | + duration_spent: "Durée passée", | |
| 487 | + record_time: "Enregistrer un temps", | |
| 488 | + time_date: "Date du temps", | |
| 489 | + reschedule: "Reprogrammer", | |
| 490 | + next_activation_aria: "Prochaine activation", | |
| 491 | + minutes_spent_aria: "Minutes passées", | |
| 492 | + validate: "Valider", | |
| 493 | + rhythm_every_pat: "Rythme constaté : tous les ~{} jours", | |
| 494 | + rhythm_every_one: "Rythme constaté : environ tous les jours", | |
| 495 | + rhythm_none_pat: "Pas encore de rythme constaté — proposition à {} jours.", | |
| 496 | + toast_todo_created: "Tâche créée ✓", | |
| 497 | + toast_todo_edited: "Tâche modifiée ✓", | |
| 498 | + toast_todo_deleted: "Tâche supprimée", | |
| 499 | + toast_todo_taken: "Tâche prise ✓", | |
| 500 | + toast_todo_released: "Tâche relâchée", | |
| 501 | + toast_todo_done: "Tâche faite ✓", | |
| 502 | + toast_todo_done_reprog: "Faite et reprogrammée ✓", | |
| 503 | + err_404: "Page introuvable.", | |
| 504 | + err_csrf: "Formulaire expiré, veuillez réessayer.", | |
| 505 | + err_500: "Erreur interne.", | |
| 506 | + back_home: "← Accueil", | |
| 507 | + weekdays: ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"], | |
| 508 | + months: [ | |
| 509 | + "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", | |
| 510 | + "septembre", "octobre", "novembre", "décembre", | |
| 511 | + ], | |
| 512 | + today_suffix: " — aujourd'hui", | |
| 513 | + week_range_pat: "Semaine du {} au {}", | |
| 514 | + year_pat: "Année {}", | |
| 515 | +}; | |
| 516 | + | |
| 517 | +pub static EN: Tr = Tr { | |
| 518 | + tab_add: "Add", | |
| 519 | + tab_stats: "Stats", | |
| 520 | + tab_todos: "Tasks", | |
| 521 | + tab_settings: "Settings", | |
| 522 | + change_team: "Switch team", | |
| 523 | + close: "Close", | |
| 524 | + member_one: "member", | |
| 525 | + member_many: "members", | |
| 526 | + start_timer: "Start the timer", | |
| 527 | + start_timer_aria: "Start timer", | |
| 528 | + stop_timer_aria: "Stop timer", | |
| 529 | + timer_running_note: "running", | |
| 530 | + record_measured_aria: "Record the measured time", | |
| 531 | + record_measured_switch_aria: "Record the measured time (switches team)", | |
| 532 | + measured_pat: "{} measured", | |
| 533 | + in_team_pat: "in team {}", | |
| 534 | + timer_in_team_pat: "timer in team {}", | |
| 535 | + login_title: "Sign in", | |
| 536 | + tagline: "Time spent on each category, shared with your team.", | |
| 537 | + username_label: "Username", | |
| 538 | + password_label: "Password", | |
| 539 | + sign_in: "Sign in", | |
| 540 | + bad_credentials: "Incorrect credentials.", | |
| 541 | + title_add: "Add time", | |
| 542 | + step1_hint: "Step 1 of 2 — pick the category", | |
| 543 | + no_categories_yet: "No categories yet — start by creating one.", | |
| 544 | + manage_categories: "Manage categories", | |
| 545 | + change_category: "← Change category", | |
| 546 | + duration_label: "Duration", | |
| 547 | + minutes_unit: "minutes", | |
| 548 | + date_label: "Date", | |
| 549 | + five_less: "Five minutes less", | |
| 550 | + five_more: "Five minutes more", | |
| 551 | + day_prev: "Previous day", | |
| 552 | + day_next: "Next day", | |
| 553 | + forgot_timer: "Forgot the timer? You can fix the duration in the next step.", | |
| 554 | + cancel_measured: "Discard this time", | |
| 555 | + measured_pick_pat: "{} measured — pick the category", | |
| 556 | + week_hours_pat: "{} this week", | |
| 557 | + added_pat: "{} added ✓", | |
| 558 | + err_duration_min: "The duration must be at least 1 minute.", | |
| 559 | + err_cat_team: "This category does not belong to the active team.", | |
| 560 | + title_timer: "Timer", | |
| 561 | + chrono_kicker: "Timer running", | |
| 562 | + started_at_pat: "Started at {}", | |
| 563 | + chrono_hint: "Finish the timer to pick a category and record this time.", | |
| 564 | + finish_timer: "Finish the timer", | |
| 565 | + cancel_timer: "Cancel the timer", | |
| 566 | + toast_measure_discarded: "Measured time discarded", | |
| 567 | + toast_timer_cancelled: "Timer cancelled", | |
| 568 | + title_stats: "Statistics", | |
| 569 | + my_entries_arrow: "My entries →", | |
| 570 | + this_week: "This week", | |
| 571 | + this_month: "This month", | |
| 572 | + this_year: "This year", | |
| 573 | + all_time: "All time", | |
| 574 | + no_time_period: "No time recorded in this period.", | |
| 575 | + all_history: "Entire history", | |
| 576 | + prev_period: "Previous period", | |
| 577 | + next_period: "Next period", | |
| 578 | + share_of_time: "Share of their time", | |
| 579 | + detail: "Detail", | |
| 580 | + entry_one: "entry", | |
| 581 | + entry_many: "entries", | |
| 582 | + title_times: "My time", | |
| 583 | + times_empty: "You haven't recorded any time in this team yet.", | |
| 584 | + see_more: "Show more", | |
| 585 | + edit_aria: "Edit", | |
| 586 | + title_edit: "Edit time", | |
| 587 | + back_times: "← My time", | |
| 588 | + category_label: "Category", | |
| 589 | + duration_min_label: "Duration (min)", | |
| 590 | + save: "Save", | |
| 591 | + delete: "Delete", | |
| 592 | + archived_suffix_pat: "{} (archived)", | |
| 593 | + toast_time_edited: "Time updated", | |
| 594 | + toast_time_deleted: "Time deleted", | |
| 595 | + title_categories: "Categories", | |
| 596 | + categories_hint_pat: "Categories belong to the team {} and can be edited by all its members. Archiving a category removes it from lists; recorded times stay in the stats.", | |
| 597 | + new_category_ph: "New category", | |
| 598 | + add: "Add", | |
| 599 | + rename: "Rename", | |
| 600 | + rename_cat_aria: "Rename the category (press Enter to confirm)", | |
| 601 | + restore: "Restore", | |
| 602 | + archive_aria: "Archive", | |
| 603 | + rename_help: "Rename: edit the name, then press Enter.", | |
| 604 | + back_add: "← Back to add", | |
| 605 | + toast_cat_added: "Category added", | |
| 606 | + toast_cat_renamed: "Category renamed", | |
| 607 | + toast_cat_archived: "Category archived — times stay in the stats", | |
| 608 | + toast_cat_restored: "Category restored", | |
| 609 | + err_cat_dup: "A category already has this name", | |
| 610 | + err_cat_empty: "The name cannot be empty", | |
| 611 | + title_settings: "Settings", | |
| 612 | + invitation_kicker: "Invitation", | |
| 613 | + invite_text_pat: "{} invites you to join the team “{}”.", | |
| 614 | + accept: "Accept", | |
| 615 | + refuse: "Decline", | |
| 616 | + account_kicker: "Account", | |
| 617 | + account_line_pat: "Member of {} teams. Accounts are created from the command line.", | |
| 618 | + account_line_single: "Accounts are created from the command line.", | |
| 619 | + my_entries: "My entries", | |
| 620 | + my_entries_desc: "View and fix the times I recorded.", | |
| 621 | + team_name_label: "Team name", | |
| 622 | + rename_team_note: "The new name is visible to all members.", | |
| 623 | + members_of_pat: "Members of {}", | |
| 624 | + invite_user_label: "Invite a user", | |
| 625 | + username_ph: "username", | |
| 626 | + invite: "Invite", | |
| 627 | + invite_note: "The user must already have an account. They receive an invitation to accept on their side.", | |
| 628 | + pending_invites: "Pending invitations", | |
| 629 | + invited_by_pat: "invited by {} · pending", | |
| 630 | + cancel: "Cancel", | |
| 631 | + create_team: "Create a team", | |
| 632 | + create: "Create", | |
| 633 | + new_team_aria: "Name of the new team", | |
| 634 | + create_team_note: "You switch to it right away — then invite whoever you want.", | |
| 635 | + leave_team_pat: "Leave “{}”", | |
| 636 | + leave_note: "Your recorded times stay in the team's stats.", | |
| 637 | + language: "Language", | |
| 638 | + session: "Session", | |
| 639 | + current_password: "Current password", | |
| 640 | + new_password: "New password", | |
| 641 | + confirmation: "Confirmation", | |
| 642 | + change_password: "Change password", | |
| 643 | + sign_out: "Sign out", | |
| 644 | + signout_note: "The session biscuit will be revoked.", | |
| 645 | + toast_lang_updated: "Language updated", | |
| 646 | + toast_password_updated: "Password updated", | |
| 647 | + toast_invite_sent: "Invitation sent", | |
| 648 | + toast_invite_refused: "Invitation declined", | |
| 649 | + toast_invite_cancelled: "Invitation cancelled", | |
| 650 | + team_created_pat: "Team {} created", | |
| 651 | + toast_team_renamed: "Team renamed", | |
| 652 | + toast_joined_pat: "You joined {}", | |
| 653 | + toast_team_left: "Team left", | |
| 654 | + err_team_name_taken: "This team name is already taken", | |
| 655 | + err_team_name_empty: "The team name cannot be empty", | |
| 656 | + err_last_team: "You cannot leave your last team", | |
| 657 | + err_password_short: "The new password must be at least 8 characters.", | |
| 658 | + err_password_mismatch: "The confirmation does not match.", | |
| 659 | + err_password_wrong: "Current password is incorrect.", | |
| 660 | + invite_unknown_pat: "No account “{}”.", | |
| 661 | + already_member_pat: "{} is already a member of the team.", | |
| 662 | + already_invited_pat: "An invitation is already pending for {}.", | |
| 663 | + title_todos: "Tasks", | |
| 664 | + new_todo: "+ New task", | |
| 665 | + todos_empty_title: "No tasks yet", | |
| 666 | + todos_empty_body_pat: "Add what needs doing in {}: anyone can take it, do it, and the time spent goes into the stats.", | |
| 667 | + doing_block: "In progress", | |
| 668 | + todo_block: "To do", | |
| 669 | + my_tasks: "My tasks", | |
| 670 | + doing_filter_empty: "No in-progress task is assigned to you.", | |
| 671 | + open_filter_empty: "No to-do task is assigned to you.", | |
| 672 | + upcoming: "Upcoming", | |
| 673 | + take: "Take", | |
| 674 | + done_check: "Done ✓", | |
| 675 | + release: "Release", | |
| 676 | + edit_todo_aria_pat: "Edit task {}", | |
| 677 | + taken_by_pat: "taken by {} · {}", | |
| 678 | + since_pat: "since {}", | |
| 679 | + activated_today: "active since today", | |
| 680 | + from_pat: "from {}", | |
| 681 | + back_todos: "← Tasks", | |
| 682 | + title_new_todo: "New task", | |
| 683 | + title_edit_todo: "Edit task", | |
| 684 | + todo_name_label: "Task name", | |
| 685 | + todo_name_ph: "Clean the bathroom", | |
| 686 | + activation_date: "Activation date", | |
| 687 | + assign_to: "Assign to", | |
| 688 | + assign_note: "Optional. Other members can still take the task.", | |
| 689 | + you_suffix: " (you)", | |
| 690 | + create_todo: "Create the task", | |
| 691 | + delete_todo: "Delete the task", | |
| 692 | + err_todo_incomplete: "Name, category and date are required.", | |
| 693 | + title_todo_done: "Task completed", | |
| 694 | + duration_spent: "Time spent", | |
| 695 | + record_time: "Record time", | |
| 696 | + time_date: "Date of the time", | |
| 697 | + reschedule: "Reschedule", | |
| 698 | + next_activation_aria: "Next activation", | |
| 699 | + minutes_spent_aria: "Minutes spent", | |
| 700 | + validate: "Confirm", | |
| 701 | + rhythm_every_pat: "Observed rhythm: about every {} days", | |
| 702 | + rhythm_every_one: "Observed rhythm: about every day", | |
| 703 | + rhythm_none_pat: "No rhythm observed yet — proposing {} days.", | |
| 704 | + toast_todo_created: "Task created ✓", | |
| 705 | + toast_todo_edited: "Task updated ✓", | |
| 706 | + toast_todo_deleted: "Task deleted", | |
| 707 | + toast_todo_taken: "Task taken ✓", | |
| 708 | + toast_todo_released: "Task released", | |
| 709 | + toast_todo_done: "Task done ✓", | |
| 710 | + toast_todo_done_reprog: "Done and rescheduled ✓", | |
| 711 | + err_404: "Page not found.", | |
| 712 | + err_csrf: "The form expired, please try again.", | |
| 713 | + err_500: "Internal error.", | |
| 714 | + back_home: "← Home", | |
| 715 | + weekdays: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], | |
| 716 | + months: [ | |
| 717 | + "January", "February", "March", "April", "May", "June", "July", "August", | |
| 718 | + "September", "October", "November", "December", | |
| 719 | + ], | |
| 720 | + today_suffix: " — today", | |
| 721 | + week_range_pat: "Week of {} to {}", | |
| 722 | + year_pat: "Year {}", | |
| 723 | +}; | |
| 724 | + | |
| 725 | +pub static DE: Tr = Tr { | |
| 726 | + tab_add: "Erfassen", | |
| 727 | + tab_stats: "Statistik", | |
| 728 | + tab_todos: "Aufgaben", | |
| 729 | + tab_settings: "Optionen", | |
| 730 | + change_team: "Team wechseln", | |
| 731 | + close: "Schließen", | |
| 732 | + member_one: "Mitglied", | |
| 733 | + member_many: "Mitglieder", | |
| 734 | + start_timer: "Timer starten", | |
| 735 | + start_timer_aria: "Timer starten", | |
| 736 | + stop_timer_aria: "Timer stoppen", | |
| 737 | + timer_running_note: "läuft", | |
| 738 | + record_measured_aria: "Gemessene Zeit erfassen", | |
| 739 | + record_measured_switch_aria: "Gemessene Zeit erfassen (wechselt das Team)", | |
| 740 | + measured_pat: "{} gemessen", | |
| 741 | + in_team_pat: "im Team {}", | |
| 742 | + timer_in_team_pat: "Timer im Team {}", | |
| 743 | + login_title: "Anmeldung", | |
| 744 | + tagline: "Die Zeit für jede Kategorie, geteilt mit deinem Team.", | |
| 745 | + username_label: "Benutzername", | |
| 746 | + password_label: "Passwort", | |
| 747 | + sign_in: "Anmelden", | |
| 748 | + bad_credentials: "Zugangsdaten sind falsch.", | |
| 749 | + title_add: "Zeit erfassen", | |
| 750 | + step1_hint: "Schritt 1 von 2 — wähle die Kategorie", | |
| 751 | + no_categories_yet: "Noch keine Kategorien — lege zuerst eine an.", | |
| 752 | + manage_categories: "Kategorien verwalten", | |
| 753 | + change_category: "← Kategorie wechseln", | |
| 754 | + duration_label: "Dauer", | |
| 755 | + minutes_unit: "Minuten", | |
| 756 | + date_label: "Datum", | |
| 757 | + five_less: "Fünf Minuten weniger", | |
| 758 | + five_more: "Fünf Minuten mehr", | |
| 759 | + day_prev: "Vortag", | |
| 760 | + day_next: "Nächster Tag", | |
| 761 | + forgot_timer: "Timer vergessen? Du kannst die Dauer im nächsten Schritt anpassen.", | |
| 762 | + cancel_measured: "Diese Zeit verwerfen", | |
| 763 | + measured_pick_pat: "{} gemessen — wähle die Kategorie", | |
| 764 | + week_hours_pat: "{} diese Woche", | |
| 765 | + added_pat: "{} erfasst ✓", | |
| 766 | + err_duration_min: "Die Dauer muss mindestens 1 Minute betragen.", | |
| 767 | + err_cat_team: "Diese Kategorie gehört nicht zum aktiven Team.", | |
| 768 | + title_timer: "Timer", | |
| 769 | + chrono_kicker: "Timer läuft", | |
| 770 | + started_at_pat: "Gestartet um {}", | |
| 771 | + chrono_hint: "Beende den Timer, um eine Kategorie zu wählen und die Zeit zu erfassen.", | |
| 772 | + finish_timer: "Timer beenden", | |
| 773 | + cancel_timer: "Timer abbrechen", | |
| 774 | + toast_measure_discarded: "Gemessene Zeit verworfen", | |
| 775 | + toast_timer_cancelled: "Timer abgebrochen", | |
| 776 | + title_stats: "Statistik", | |
| 777 | + my_entries_arrow: "Meine Einträge →", | |
| 778 | + this_week: "Diese Woche", | |
| 779 | + this_month: "Dieser Monat", | |
| 780 | + this_year: "Dieses Jahr", | |
| 781 | + all_time: "Seit Beginn", | |
| 782 | + no_time_period: "In diesem Zeitraum wurde keine Zeit erfasst.", | |
| 783 | + all_history: "Gesamter Verlauf", | |
| 784 | + prev_period: "Vorheriger Zeitraum", | |
| 785 | + next_period: "Nächster Zeitraum", | |
| 786 | + share_of_time: "Zeitanteil", | |
| 787 | + detail: "Detail", | |
| 788 | + entry_one: "Eintrag", | |
| 789 | + entry_many: "Einträge", | |
| 790 | + title_times: "Meine Zeiten", | |
| 791 | + times_empty: "Du hast in diesem Team noch keine Zeit erfasst.", | |
| 792 | + see_more: "Mehr anzeigen", | |
| 793 | + edit_aria: "Bearbeiten", | |
| 794 | + title_edit: "Zeit bearbeiten", | |
| 795 | + back_times: "← Meine Zeiten", | |
| 796 | + category_label: "Kategorie", | |
| 797 | + duration_min_label: "Dauer (Min.)", | |
| 798 | + save: "Speichern", | |
| 799 | + delete: "Löschen", | |
| 800 | + archived_suffix_pat: "{} (archiviert)", | |
| 801 | + toast_time_edited: "Zeit aktualisiert", | |
| 802 | + toast_time_deleted: "Zeit gelöscht", | |
| 803 | + title_categories: "Kategorien", | |
| 804 | + categories_hint_pat: "Kategorien gehören zum Team {} und können von allen Mitgliedern bearbeitet werden. Archivieren entfernt eine Kategorie aus den Listen; erfasste Zeiten bleiben in der Statistik.", | |
| 805 | + new_category_ph: "Neue Kategorie", | |
| 806 | + add: "Hinzufügen", | |
| 807 | + rename: "Umbenennen", | |
| 808 | + rename_cat_aria: "Kategorie umbenennen (Enter zum Bestätigen)", | |
| 809 | + restore: "Wiederherstellen", | |
| 810 | + archive_aria: "Archivieren", | |
| 811 | + rename_help: "Umbenennen: Namen ändern und Enter drücken.", | |
| 812 | + back_add: "← Zurück zur Erfassung", | |
| 813 | + toast_cat_added: "Kategorie hinzugefügt", | |
| 814 | + toast_cat_renamed: "Kategorie umbenannt", | |
| 815 | + toast_cat_archived: "Kategorie archiviert — Zeiten bleiben in der Statistik", | |
| 816 | + toast_cat_restored: "Kategorie wiederhergestellt", | |
| 817 | + err_cat_dup: "Eine Kategorie trägt bereits diesen Namen", | |
| 818 | + err_cat_empty: "Der Name darf nicht leer sein", | |
| 819 | + title_settings: "Einstellungen", | |
| 820 | + invitation_kicker: "Einladung", | |
| 821 | + invite_text_pat: "{} lädt dich ins Team „{}“ ein.", | |
| 822 | + accept: "Annehmen", | |
| 823 | + refuse: "Ablehnen", | |
| 824 | + account_kicker: "Konto", | |
| 825 | + account_line_pat: "Mitglied in {} Teams. Konten werden über die Kommandozeile angelegt.", | |
| 826 | + account_line_single: "Konten werden über die Kommandozeile angelegt.", | |
| 827 | + my_entries: "Meine Einträge", | |
| 828 | + my_entries_desc: "Erfasste Zeiten ansehen und korrigieren.", | |
| 829 | + team_name_label: "Teamname", | |
| 830 | + rename_team_note: "Der neue Name ist für alle Mitglieder sichtbar.", | |
| 831 | + members_of_pat: "Mitglieder von {}", | |
| 832 | + invite_user_label: "Benutzer einladen", | |
| 833 | + username_ph: "benutzername", | |
| 834 | + invite: "Einladen", | |
| 835 | + invite_note: "Der Benutzer braucht bereits ein Konto. Er erhält eine Einladung zum Annehmen.", | |
| 836 | + pending_invites: "Ausstehende Einladungen", | |
| 837 | + invited_by_pat: "eingeladen von {} · ausstehend", | |
| 838 | + cancel: "Abbrechen", | |
| 839 | + create_team: "Team erstellen", | |
| 840 | + create: "Erstellen", | |
| 841 | + new_team_aria: "Name des neuen Teams", | |
| 842 | + create_team_note: "Du wechselst sofort dorthin — lade danach ein, wen du möchtest.", | |
| 843 | + leave_team_pat: "„{}“ verlassen", | |
| 844 | + leave_note: "Deine erfassten Zeiten bleiben in der Team-Statistik.", | |
| 845 | + language: "Sprache", | |
| 846 | + session: "Sitzung", | |
| 847 | + current_password: "Aktuelles Passwort", | |
| 848 | + new_password: "Neues Passwort", | |
| 849 | + confirmation: "Bestätigung", | |
| 850 | + change_password: "Passwort ändern", | |
| 851 | + sign_out: "Abmelden", | |
| 852 | + signout_note: "Das Sitzungs-Biscuit wird widerrufen.", | |
| 853 | + toast_lang_updated: "Sprache aktualisiert", | |
| 854 | + toast_password_updated: "Passwort aktualisiert", | |
| 855 | + toast_invite_sent: "Einladung gesendet", | |
| 856 | + toast_invite_refused: "Einladung abgelehnt", | |
| 857 | + toast_invite_cancelled: "Einladung zurückgezogen", | |
| 858 | + team_created_pat: "Team {} erstellt", | |
| 859 | + toast_team_renamed: "Team umbenannt", | |
| 860 | + toast_joined_pat: "Du bist {} beigetreten", | |
| 861 | + toast_team_left: "Team verlassen", | |
| 862 | + err_team_name_taken: "Dieser Teamname ist bereits vergeben", | |
| 863 | + err_team_name_empty: "Der Teamname darf nicht leer sein", | |
| 864 | + err_last_team: "Du kannst dein letztes Team nicht verlassen", | |
| 865 | + err_password_short: "Das neue Passwort muss mindestens 8 Zeichen lang sein.", | |
| 866 | + err_password_mismatch: "Die Bestätigung stimmt nicht überein.", | |
| 867 | + err_password_wrong: "Aktuelles Passwort ist falsch.", | |
| 868 | + invite_unknown_pat: "Kein Konto „{}“.", | |
| 869 | + already_member_pat: "{} ist bereits Mitglied des Teams.", | |
| 870 | + already_invited_pat: "Für {} steht bereits eine Einladung aus.", | |
| 871 | + title_todos: "Aufgaben", | |
| 872 | + new_todo: "+ Neue Aufgabe", | |
| 873 | + todos_empty_title: "Noch keine Aufgaben", | |
| 874 | + todos_empty_body_pat: "Trage ein, was in {} zu tun ist: jeder kann die Aufgabe übernehmen, erledigen, und die Zeit fließt in die Statistik.", | |
| 875 | + doing_block: "In Arbeit", | |
| 876 | + todo_block: "Zu erledigen", | |
| 877 | + my_tasks: "Meine Aufgaben", | |
| 878 | + doing_filter_empty: "Dir ist keine laufende Aufgabe zugewiesen.", | |
| 879 | + open_filter_empty: "Dir ist keine offene Aufgabe zugewiesen.", | |
| 880 | + upcoming: "Demnächst", | |
| 881 | + take: "Übernehmen", | |
| 882 | + done_check: "Erledigt ✓", | |
| 883 | + release: "Freigeben", | |
| 884 | + edit_todo_aria_pat: "Aufgabe {} bearbeiten", | |
| 885 | + taken_by_pat: "übernommen von {} · {}", | |
| 886 | + since_pat: "seit dem {}", | |
| 887 | + activated_today: "seit heute aktiv", | |
| 888 | + from_pat: "ab dem {}", | |
| 889 | + back_todos: "← Aufgaben", | |
| 890 | + title_new_todo: "Neue Aufgabe", | |
| 891 | + title_edit_todo: "Aufgabe bearbeiten", | |
| 892 | + todo_name_label: "Name der Aufgabe", | |
| 893 | + todo_name_ph: "Das Bad putzen", | |
| 894 | + activation_date: "Aktivierungsdatum", | |
| 895 | + assign_to: "Zuweisen an", | |
| 896 | + assign_note: "Optional. Andere Mitglieder können die Aufgabe trotzdem übernehmen.", | |
| 897 | + you_suffix: " (du)", | |
| 898 | + create_todo: "Aufgabe erstellen", | |
| 899 | + delete_todo: "Aufgabe löschen", | |
| 900 | + err_todo_incomplete: "Name, Kategorie und Datum sind erforderlich.", | |
| 901 | + title_todo_done: "Aufgabe erledigt", | |
| 902 | + duration_spent: "Aufgewendete Zeit", | |
| 903 | + record_time: "Zeit erfassen", | |
| 904 | + time_date: "Datum der Zeit", | |
| 905 | + reschedule: "Neu planen", | |
| 906 | + next_activation_aria: "Nächste Aktivierung", | |
| 907 | + minutes_spent_aria: "Aufgewendete Minuten", | |
| 908 | + validate: "Bestätigen", | |
| 909 | + rhythm_every_pat: "Beobachteter Rhythmus: etwa alle {} Tage", | |
| 910 | + rhythm_every_one: "Beobachteter Rhythmus: etwa täglich", | |
| 911 | + rhythm_none_pat: "Noch kein Rhythmus erkennbar — Vorschlag: {} Tage.", | |
| 912 | + toast_todo_created: "Aufgabe erstellt ✓", | |
| 913 | + toast_todo_edited: "Aufgabe aktualisiert ✓", | |
| 914 | + toast_todo_deleted: "Aufgabe gelöscht", | |
| 915 | + toast_todo_taken: "Aufgabe übernommen ✓", | |
| 916 | + toast_todo_released: "Aufgabe freigegeben", | |
| 917 | + toast_todo_done: "Aufgabe erledigt ✓", | |
| 918 | + toast_todo_done_reprog: "Erledigt und neu geplant ✓", | |
| 919 | + err_404: "Seite nicht gefunden.", | |
| 920 | + err_csrf: "Das Formular ist abgelaufen, bitte erneut versuchen.", | |
| 921 | + err_500: "Interner Fehler.", | |
| 922 | + back_home: "← Startseite", | |
| 923 | + weekdays: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"], | |
| 924 | + months: [ | |
| 925 | + "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", | |
| 926 | + "September", "Oktober", "November", "Dezember", | |
| 927 | + ], | |
| 928 | + today_suffix: " — heute", | |
| 929 | + week_range_pat: "Woche vom {} bis {}", | |
| 930 | + year_pat: "Jahr {}", | |
| 931 | +}; | |
| 932 | + | |
| 933 | +#[cfg(test)] | |
| 934 | +mod tests { | |
| 935 | + use super::*; | |
| 936 | + | |
| 937 | + #[test] | |
| 938 | + fn patterns_interpolate() { | |
| 939 | + assert_eq!(p1(FR.since_pat, "3 août"), "depuis le 3 août"); | |
| 940 | + assert_eq!(p2(EN.invite_text_pat, "alice", "Garden"), "alice invites you to join the team “Garden”."); | |
| 941 | + assert_eq!(DE.members_count(1), "1 Mitglied"); | |
| 942 | + assert_eq!(DE.members_count(3), "3 Mitglieder"); | |
| 943 | + assert_eq!(EN.entries_count(2), "2 entries"); | |
| 944 | + assert_eq!(FR.account_line(1), FR.account_line_single); | |
| 945 | + } | |
| 946 | + | |
| 947 | + #[test] | |
| 948 | + fn lang_codes_roundtrip() { | |
| 949 | + for l in Lang::ALL { | |
| 950 | + assert_eq!(Lang::from_code(l.code()), Some(l)); | |
| 951 | + } | |
| 952 | + assert_eq!(Lang::from_code("es"), None); | |
| 953 | + } | |
| 954 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,954 @@ | |||
| 1 | +//! Traductions FR / EN / DE. La langue est PAR UTILISATEUR, stockée en base | ||
| 2 | +//! (`users.lang`) — jamais par navigateur. Défaut à la création d'un compte : | ||
| 3 | +//! `DEFAULT_LANG` (fr sinon). Les pages anonymes (login, erreurs) utilisent | ||
| 4 | +//! la langue par défaut. | ||
| 5 | +//! | ||
| 6 | +//! Principe : `Tr` est une table de chaînes statiques, une instance par | ||
| 7 | +//! langue. Les templates lisent `shell.tr.*` ; les chaînes à interpolation | ||
| 8 | +//! (motifs `{}`) sont résolues côté Rust avec `p1`/`p2`. | ||
| 9 | + | ||
| 10 | +#[derive(Clone, Copy, PartialEq, Eq, Debug)] | ||
| 11 | +pub enum Lang { | ||
| 12 | + Fr, | ||
| 13 | + En, | ||
| 14 | + De, | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +impl Lang { | ||
| 18 | + pub fn from_code(code: &str) -> Option<Lang> { | ||
| 19 | + match code { | ||
| 20 | + "fr" => Some(Lang::Fr), | ||
| 21 | + "en" => Some(Lang::En), | ||
| 22 | + "de" => Some(Lang::De), | ||
| 23 | + _ => None, | ||
| 24 | + } | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + pub fn code(self) -> &'static str { | ||
| 28 | + match self { | ||
| 29 | + Lang::Fr => "fr", | ||
| 30 | + Lang::En => "en", | ||
| 31 | + Lang::De => "de", | ||
| 32 | + } | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + pub fn tr(self) -> &'static Tr { | ||
| 36 | + match self { | ||
| 37 | + Lang::Fr => &FR, | ||
| 38 | + Lang::En => &EN, | ||
| 39 | + Lang::De => &DE, | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + /// Nom de la langue, dans cette langue (libellés du sélecteur). | ||
| 44 | + pub fn self_name(self) -> &'static str { | ||
| 45 | + match self { | ||
| 46 | + Lang::Fr => "Français", | ||
| 47 | + Lang::En => "English", | ||
| 48 | + Lang::De => "Deutsch", | ||
| 49 | + } | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + pub const ALL: [Lang; 3] = [Lang::Fr, Lang::En, Lang::De]; | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +/// Interpolation à un trou (motifs `{}` des champs `*_pat`). | ||
| 56 | +pub fn p1(pat: &str, a: impl std::fmt::Display) -> String { | ||
| 57 | + pat.replacen("{}", &a.to_string(), 1) | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +/// Interpolation à deux trous, dans l'ordre. | ||
| 61 | +pub fn p2(pat: &str, a: impl std::fmt::Display, b: impl std::fmt::Display) -> String { | ||
| 62 | + p1(&p1(pat, a), b) | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +pub struct Tr { | ||
| 66 | + // ─── shell : tab bar, pill, sheets, timer ─── | ||
| 67 | + pub tab_add: &'static str, | ||
| 68 | + pub tab_stats: &'static str, | ||
| 69 | + pub tab_todos: &'static str, | ||
| 70 | + pub tab_settings: &'static str, | ||
| 71 | + pub change_team: &'static str, | ||
| 72 | + pub close: &'static str, | ||
| 73 | + pub member_one: &'static str, | ||
| 74 | + pub member_many: &'static str, | ||
| 75 | + pub start_timer: &'static str, | ||
| 76 | + pub start_timer_aria: &'static str, | ||
| 77 | + pub stop_timer_aria: &'static str, | ||
| 78 | + pub timer_running_note: &'static str, | ||
| 79 | + pub record_measured_aria: &'static str, | ||
| 80 | + pub record_measured_switch_aria: &'static str, | ||
| 81 | + pub measured_pat: &'static str, | ||
| 82 | + pub in_team_pat: &'static str, | ||
| 83 | + pub timer_in_team_pat: &'static str, | ||
| 84 | + // ─── login ─── | ||
| 85 | + pub login_title: &'static str, | ||
| 86 | + pub tagline: &'static str, | ||
| 87 | + pub username_label: &'static str, | ||
| 88 | + pub password_label: &'static str, | ||
| 89 | + pub sign_in: &'static str, | ||
| 90 | + pub bad_credentials: &'static str, | ||
| 91 | + // ─── ajout ─── | ||
| 92 | + pub title_add: &'static str, | ||
| 93 | + pub step1_hint: &'static str, | ||
| 94 | + pub no_categories_yet: &'static str, | ||
| 95 | + pub manage_categories: &'static str, | ||
| 96 | + pub change_category: &'static str, | ||
| 97 | + pub duration_label: &'static str, | ||
| 98 | + pub minutes_unit: &'static str, | ||
| 99 | + pub date_label: &'static str, | ||
| 100 | + pub five_less: &'static str, | ||
| 101 | + pub five_more: &'static str, | ||
| 102 | + pub day_prev: &'static str, | ||
| 103 | + pub day_next: &'static str, | ||
| 104 | + pub forgot_timer: &'static str, | ||
| 105 | + pub cancel_measured: &'static str, | ||
| 106 | + pub measured_pick_pat: &'static str, | ||
| 107 | + pub week_hours_pat: &'static str, | ||
| 108 | + pub added_pat: &'static str, | ||
| 109 | + pub err_duration_min: &'static str, | ||
| 110 | + pub err_cat_team: &'static str, | ||
| 111 | + // ─── minuteur ─── | ||
| 112 | + pub title_timer: &'static str, | ||
| 113 | + pub chrono_kicker: &'static str, | ||
| 114 | + pub started_at_pat: &'static str, | ||
| 115 | + pub chrono_hint: &'static str, | ||
| 116 | + pub finish_timer: &'static str, | ||
| 117 | + pub cancel_timer: &'static str, | ||
| 118 | + pub toast_measure_discarded: &'static str, | ||
| 119 | + pub toast_timer_cancelled: &'static str, | ||
| 120 | + // ─── stats ─── | ||
| 121 | + pub title_stats: &'static str, | ||
| 122 | + pub my_entries_arrow: &'static str, | ||
| 123 | + pub this_week: &'static str, | ||
| 124 | + pub this_month: &'static str, | ||
| 125 | + pub this_year: &'static str, | ||
| 126 | + pub all_time: &'static str, | ||
| 127 | + pub no_time_period: &'static str, | ||
| 128 | + pub all_history: &'static str, | ||
| 129 | + pub prev_period: &'static str, | ||
| 130 | + pub next_period: &'static str, | ||
| 131 | + pub share_of_time: &'static str, | ||
| 132 | + pub detail: &'static str, | ||
| 133 | + pub entry_one: &'static str, | ||
| 134 | + pub entry_many: &'static str, | ||
| 135 | + // ─── mes temps / édition ─── | ||
| 136 | + pub title_times: &'static str, | ||
| 137 | + pub times_empty: &'static str, | ||
| 138 | + pub see_more: &'static str, | ||
| 139 | + pub edit_aria: &'static str, | ||
| 140 | + pub title_edit: &'static str, | ||
| 141 | + pub back_times: &'static str, | ||
| 142 | + pub category_label: &'static str, | ||
| 143 | + pub duration_min_label: &'static str, | ||
| 144 | + pub save: &'static str, | ||
| 145 | + pub delete: &'static str, | ||
| 146 | + pub archived_suffix_pat: &'static str, | ||
| 147 | + pub toast_time_edited: &'static str, | ||
| 148 | + pub toast_time_deleted: &'static str, | ||
| 149 | + // ─── catégories ─── | ||
| 150 | + pub title_categories: &'static str, | ||
| 151 | + pub categories_hint_pat: &'static str, | ||
| 152 | + pub new_category_ph: &'static str, | ||
| 153 | + pub add: &'static str, | ||
| 154 | + pub rename: &'static str, | ||
| 155 | + pub rename_cat_aria: &'static str, | ||
| 156 | + pub restore: &'static str, | ||
| 157 | + pub archive_aria: &'static str, | ||
| 158 | + pub rename_help: &'static str, | ||
| 159 | + pub back_add: &'static str, | ||
| 160 | + pub toast_cat_added: &'static str, | ||
| 161 | + pub toast_cat_renamed: &'static str, | ||
| 162 | + pub toast_cat_archived: &'static str, | ||
| 163 | + pub toast_cat_restored: &'static str, | ||
| 164 | + pub err_cat_dup: &'static str, | ||
| 165 | + pub err_cat_empty: &'static str, | ||
| 166 | + // ─── réglages ─── | ||
| 167 | + pub title_settings: &'static str, | ||
| 168 | + pub invitation_kicker: &'static str, | ||
| 169 | + pub invite_text_pat: &'static str, | ||
| 170 | + pub accept: &'static str, | ||
| 171 | + pub refuse: &'static str, | ||
| 172 | + pub account_kicker: &'static str, | ||
| 173 | + pub account_line_pat: &'static str, | ||
| 174 | + pub account_line_single: &'static str, | ||
| 175 | + pub my_entries: &'static str, | ||
| 176 | + pub my_entries_desc: &'static str, | ||
| 177 | + pub team_name_label: &'static str, | ||
| 178 | + pub rename_team_note: &'static str, | ||
| 179 | + pub members_of_pat: &'static str, | ||
| 180 | + pub invite_user_label: &'static str, | ||
| 181 | + pub username_ph: &'static str, | ||
| 182 | + pub invite: &'static str, | ||
| 183 | + pub invite_note: &'static str, | ||
| 184 | + pub pending_invites: &'static str, | ||
| 185 | + pub invited_by_pat: &'static str, | ||
| 186 | + pub cancel: &'static str, | ||
| 187 | + pub create_team: &'static str, | ||
| 188 | + pub create: &'static str, | ||
| 189 | + pub new_team_aria: &'static str, | ||
| 190 | + pub create_team_note: &'static str, | ||
| 191 | + pub leave_team_pat: &'static str, | ||
| 192 | + pub leave_note: &'static str, | ||
| 193 | + pub language: &'static str, | ||
| 194 | + pub session: &'static str, | ||
| 195 | + pub current_password: &'static str, | ||
| 196 | + pub new_password: &'static str, | ||
| 197 | + pub confirmation: &'static str, | ||
| 198 | + pub change_password: &'static str, | ||
| 199 | + pub sign_out: &'static str, | ||
| 200 | + pub signout_note: &'static str, | ||
| 201 | + pub toast_lang_updated: &'static str, | ||
| 202 | + pub toast_password_updated: &'static str, | ||
| 203 | + pub toast_invite_sent: &'static str, | ||
| 204 | + pub toast_invite_refused: &'static str, | ||
| 205 | + pub toast_invite_cancelled: &'static str, | ||
| 206 | + pub team_created_pat: &'static str, | ||
| 207 | + pub toast_team_renamed: &'static str, | ||
| 208 | + pub toast_joined_pat: &'static str, | ||
| 209 | + pub toast_team_left: &'static str, | ||
| 210 | + pub err_team_name_taken: &'static str, | ||
| 211 | + pub err_team_name_empty: &'static str, | ||
| 212 | + pub err_last_team: &'static str, | ||
| 213 | + pub err_password_short: &'static str, | ||
| 214 | + pub err_password_mismatch: &'static str, | ||
| 215 | + pub err_password_wrong: &'static str, | ||
| 216 | + pub invite_unknown_pat: &'static str, | ||
| 217 | + pub already_member_pat: &'static str, | ||
| 218 | + pub already_invited_pat: &'static str, | ||
| 219 | + // ─── todos ─── | ||
| 220 | + pub title_todos: &'static str, | ||
| 221 | + pub new_todo: &'static str, | ||
| 222 | + pub todos_empty_title: &'static str, | ||
| 223 | + pub todos_empty_body_pat: &'static str, | ||
| 224 | + pub doing_block: &'static str, | ||
| 225 | + pub todo_block: &'static str, | ||
| 226 | + pub my_tasks: &'static str, | ||
| 227 | + pub doing_filter_empty: &'static str, | ||
| 228 | + pub open_filter_empty: &'static str, | ||
| 229 | + pub upcoming: &'static str, | ||
| 230 | + pub take: &'static str, | ||
| 231 | + pub done_check: &'static str, | ||
| 232 | + pub release: &'static str, | ||
| 233 | + pub edit_todo_aria_pat: &'static str, | ||
| 234 | + pub taken_by_pat: &'static str, | ||
| 235 | + pub since_pat: &'static str, | ||
| 236 | + pub activated_today: &'static str, | ||
| 237 | + pub from_pat: &'static str, | ||
| 238 | + pub back_todos: &'static str, | ||
| 239 | + pub title_new_todo: &'static str, | ||
| 240 | + pub title_edit_todo: &'static str, | ||
| 241 | + pub todo_name_label: &'static str, | ||
| 242 | + pub todo_name_ph: &'static str, | ||
| 243 | + pub activation_date: &'static str, | ||
| 244 | + pub assign_to: &'static str, | ||
| 245 | + pub assign_note: &'static str, | ||
| 246 | + pub you_suffix: &'static str, | ||
| 247 | + pub create_todo: &'static str, | ||
| 248 | + pub delete_todo: &'static str, | ||
| 249 | + pub err_todo_incomplete: &'static str, | ||
| 250 | + pub title_todo_done: &'static str, | ||
| 251 | + pub duration_spent: &'static str, | ||
| 252 | + pub record_time: &'static str, | ||
| 253 | + pub time_date: &'static str, | ||
| 254 | + pub reschedule: &'static str, | ||
| 255 | + pub next_activation_aria: &'static str, | ||
| 256 | + pub minutes_spent_aria: &'static str, | ||
| 257 | + pub validate: &'static str, | ||
| 258 | + pub rhythm_every_pat: &'static str, | ||
| 259 | + pub rhythm_every_one: &'static str, | ||
| 260 | + pub rhythm_none_pat: &'static str, | ||
| 261 | + pub toast_todo_created: &'static str, | ||
| 262 | + pub toast_todo_edited: &'static str, | ||
| 263 | + pub toast_todo_deleted: &'static str, | ||
| 264 | + pub toast_todo_taken: &'static str, | ||
| 265 | + pub toast_todo_released: &'static str, | ||
| 266 | + pub toast_todo_done: &'static str, | ||
| 267 | + pub toast_todo_done_reprog: &'static str, | ||
| 268 | + // ─── erreurs ─── | ||
| 269 | + pub err_404: &'static str, | ||
| 270 | + pub err_csrf: &'static str, | ||
| 271 | + pub err_500: &'static str, | ||
| 272 | + pub back_home: &'static str, | ||
| 273 | + // ─── dates ─── | ||
| 274 | + pub weekdays: [&'static str; 7], | ||
| 275 | + pub months: [&'static str; 12], | ||
| 276 | + pub today_suffix: &'static str, | ||
| 277 | + pub week_range_pat: &'static str, | ||
| 278 | + pub year_pat: &'static str, | ||
| 279 | +} | ||
| 280 | + | ||
| 281 | +impl Tr { | ||
| 282 | + /// « 3 membres » / "3 members" / „3 Mitglieder“. | ||
| 283 | + pub fn members_count(&self, n: i64) -> String { | ||
| 284 | + format!("{n} {}", if n > 1 { self.member_many } else { self.member_one }) | ||
| 285 | + } | ||
| 286 | + | ||
| 287 | + /// « 3 saisies » / "3 entries" / „3 Einträge“. | ||
| 288 | + pub fn entries_count(&self, n: i64) -> String { | ||
| 289 | + format!("{n} {}", if n > 1 { self.entry_many } else { self.entry_one }) | ||
| 290 | + } | ||
| 291 | + | ||
| 292 | + pub fn account_line(&self, teams: i64) -> String { | ||
| 293 | + if teams > 1 { | ||
| 294 | + p1(self.account_line_pat, teams) | ||
| 295 | + } else { | ||
| 296 | + self.account_line_single.to_string() | ||
| 297 | + } | ||
| 298 | + } | ||
| 299 | + | ||
| 300 | + pub fn rhythm_every(&self, days: i64) -> String { | ||
| 301 | + if days == 1 { | ||
| 302 | + self.rhythm_every_one.to_string() | ||
| 303 | + } else { | ||
| 304 | + p1(self.rhythm_every_pat, days) | ||
| 305 | + } | ||
| 306 | + } | ||
| 307 | +} | ||
| 308 | + | ||
| 309 | +pub static FR: Tr = Tr { | ||
| 310 | + tab_add: "Ajouter", | ||
| 311 | + tab_stats: "Stats", | ||
| 312 | + tab_todos: "Tâches", | ||
| 313 | + tab_settings: "Réglages", | ||
| 314 | + change_team: "Changer d'équipe", | ||
| 315 | + close: "Fermer", | ||
| 316 | + member_one: "membre", | ||
| 317 | + member_many: "membres", | ||
| 318 | + start_timer: "Lancer le minuteur", | ||
| 319 | + start_timer_aria: "Démarrer le timer", | ||
| 320 | + stop_timer_aria: "Arrêter le timer", | ||
| 321 | + timer_running_note: "en cours", | ||
| 322 | + record_measured_aria: "Enregistrer le temps mesuré", | ||
| 323 | + record_measured_switch_aria: "Enregistrer le temps mesuré (bascule d'équipe)", | ||
| 324 | + measured_pat: "{} mesurées", | ||
| 325 | + in_team_pat: "dans l'équipe {}", | ||
| 326 | + timer_in_team_pat: "timer dans l'équipe {}", | ||
| 327 | + login_title: "Connexion", | ||
| 328 | + tagline: "Le temps passé sur chaque catégorie, partagé avec ton équipe.", | ||
| 329 | + username_label: "Identifiant", | ||
| 330 | + password_label: "Mot de passe", | ||
| 331 | + sign_in: "Se connecter", | ||
| 332 | + bad_credentials: "Identifiants incorrects.", | ||
| 333 | + title_add: "Ajouter du temps", | ||
| 334 | + step1_hint: "Étape 1 sur 2 — choisis la catégorie", | ||
| 335 | + no_categories_yet: "Aucune catégorie pour l'instant — commence par en créer une.", | ||
| 336 | + manage_categories: "Gérer les catégories", | ||
| 337 | + change_category: "← Changer de catégorie", | ||
| 338 | + duration_label: "Durée", | ||
| 339 | + minutes_unit: "minutes", | ||
| 340 | + date_label: "Date", | ||
| 341 | + five_less: "Cinq minutes de moins", | ||
| 342 | + five_more: "Cinq minutes de plus", | ||
| 343 | + day_prev: "Jour précédent", | ||
| 344 | + day_next: "Jour suivant", | ||
| 345 | + forgot_timer: "Timer oublié ? Tu pourras corriger la durée à l'étape suivante.", | ||
| 346 | + cancel_measured: "Annuler ce temps", | ||
| 347 | + measured_pick_pat: "{} mesurées — choisis la catégorie", | ||
| 348 | + week_hours_pat: "{} cette semaine", | ||
| 349 | + added_pat: "{} ajouté ✓", | ||
| 350 | + err_duration_min: "La durée doit être d'au moins 1 minute.", | ||
| 351 | + err_cat_team: "Cette catégorie n'appartient pas à l'équipe active.", | ||
| 352 | + title_timer: "Minuteur", | ||
| 353 | + chrono_kicker: "Minuteur en cours", | ||
| 354 | + started_at_pat: "Démarré à {}", | ||
| 355 | + chrono_hint: "Termine le minuteur pour choisir la catégorie et enregistrer ce temps.", | ||
| 356 | + finish_timer: "Finir le minuteur", | ||
| 357 | + cancel_timer: "Annuler le minuteur", | ||
| 358 | + toast_measure_discarded: "Temps mesuré annulé", | ||
| 359 | + toast_timer_cancelled: "Minuteur annulé", | ||
| 360 | + title_stats: "Statistiques", | ||
| 361 | + my_entries_arrow: "Mes saisies →", | ||
| 362 | + this_week: "Cette semaine", | ||
| 363 | + this_month: "Ce mois-ci", | ||
| 364 | + this_year: "Cette année", | ||
| 365 | + all_time: "Depuis le début", | ||
| 366 | + no_time_period: "Aucun temps enregistré sur cette période.", | ||
| 367 | + all_history: "Tout l'historique", | ||
| 368 | + prev_period: "Période précédente", | ||
| 369 | + next_period: "Période suivante", | ||
| 370 | + share_of_time: "Part de son temps", | ||
| 371 | + detail: "Détail", | ||
| 372 | + entry_one: "saisie", | ||
| 373 | + entry_many: "saisies", | ||
| 374 | + title_times: "Mes temps", | ||
| 375 | + times_empty: "Tu n'as encore enregistré aucun temps dans cette équipe.", | ||
| 376 | + see_more: "Voir plus", | ||
| 377 | + edit_aria: "Modifier", | ||
| 378 | + title_edit: "Modifier le temps", | ||
| 379 | + back_times: "← Mes temps", | ||
| 380 | + category_label: "Catégorie", | ||
| 381 | + duration_min_label: "Durée (min)", | ||
| 382 | + save: "Enregistrer", | ||
| 383 | + delete: "Supprimer", | ||
| 384 | + archived_suffix_pat: "{} (archivée)", | ||
| 385 | + toast_time_edited: "Temps modifié", | ||
| 386 | + toast_time_deleted: "Temps supprimé", | ||
| 387 | + title_categories: "Catégories", | ||
| 388 | + categories_hint_pat: "Les catégories appartiennent à l'équipe {} et sont modifiables par tous ses membres. Archiver une catégorie la retire des listes ; les temps déjà saisis restent dans les stats.", | ||
| 389 | + new_category_ph: "Nouvelle catégorie", | ||
| 390 | + add: "Ajouter", | ||
| 391 | + rename: "Renommer", | ||
| 392 | + rename_cat_aria: "Renommer la catégorie (Entrée pour valider)", | ||
| 393 | + restore: "Restaurer", | ||
| 394 | + archive_aria: "Archiver", | ||
| 395 | + rename_help: "Renommer : modifie le nom puis appuie sur Entrée.", | ||
| 396 | + back_add: "← Retour à l'ajout", | ||
| 397 | + toast_cat_added: "Catégorie ajoutée", | ||
| 398 | + toast_cat_renamed: "Catégorie renommée", | ||
| 399 | + toast_cat_archived: "Catégorie archivée — les temps restent dans les stats", | ||
| 400 | + toast_cat_restored: "Catégorie restaurée", | ||
| 401 | + err_cat_dup: "Une catégorie porte déjà ce nom", | ||
| 402 | + err_cat_empty: "Le nom ne peut pas être vide", | ||
| 403 | + title_settings: "Paramètres", | ||
| 404 | + invitation_kicker: "Invitation", | ||
| 405 | + invite_text_pat: "{} t’invite à rejoindre l’équipe « {} ».", | ||
| 406 | + accept: "Accepter", | ||
| 407 | + refuse: "Refuser", | ||
| 408 | + account_kicker: "Compte", | ||
| 409 | + account_line_pat: "Membre de {} équipes. Les comptes sont créés en ligne de commande.", | ||
| 410 | + account_line_single: "Les comptes sont créés en ligne de commande.", | ||
| 411 | + my_entries: "Mes saisies", | ||
| 412 | + my_entries_desc: "Consulter et corriger les temps que j'ai enregistrés.", | ||
| 413 | + team_name_label: "Nom de l'équipe", | ||
| 414 | + rename_team_note: "Le nouveau nom est visible par tous les membres.", | ||
| 415 | + members_of_pat: "Membres de {}", | ||
| 416 | + invite_user_label: "Inviter un utilisateur", | ||
| 417 | + username_ph: "identifiant", | ||
| 418 | + invite: "Inviter", | ||
| 419 | + invite_note: "L'utilisateur doit déjà avoir un compte. Il reçoit une invitation à accepter de son côté.", | ||
| 420 | + pending_invites: "Invitations en attente", | ||
| 421 | + invited_by_pat: "invité par {} · en attente", | ||
| 422 | + cancel: "Annuler", | ||
| 423 | + create_team: "Créer une équipe", | ||
| 424 | + create: "Créer", | ||
| 425 | + new_team_aria: "Nom de la nouvelle équipe", | ||
| 426 | + create_team_note: "Tu bascules dessus aussitôt — invite ensuite qui tu veux.", | ||
| 427 | + leave_team_pat: "Quitter « {} »", | ||
| 428 | + leave_note: "Tes temps déjà saisis restent dans les stats de l'équipe.", | ||
| 429 | + language: "Langue", | ||
| 430 | + session: "Session", | ||
| 431 | + current_password: "Mot de passe actuel", | ||
| 432 | + new_password: "Nouveau mot de passe", | ||
| 433 | + confirmation: "Confirmation", | ||
| 434 | + change_password: "Changer le mot de passe", | ||
| 435 | + sign_out: "Se déconnecter", | ||
| 436 | + signout_note: "Le biscuit de session sera révoqué.", | ||
| 437 | + toast_lang_updated: "Langue mise à jour", | ||
| 438 | + toast_password_updated: "Mot de passe mis à jour", | ||
| 439 | + toast_invite_sent: "Invitation envoyée", | ||
| 440 | + toast_invite_refused: "Invitation refusée", | ||
| 441 | + toast_invite_cancelled: "Invitation annulée", | ||
| 442 | + team_created_pat: "Équipe {} créée", | ||
| 443 | + toast_team_renamed: "Équipe renommée", | ||
| 444 | + toast_joined_pat: "Tu as rejoint {}", | ||
| 445 | + toast_team_left: "Équipe quittée", | ||
| 446 | + err_team_name_taken: "Ce nom d'équipe est déjà pris", | ||
| 447 | + err_team_name_empty: "Le nom d'équipe ne peut pas être vide", | ||
| 448 | + err_last_team: "Impossible de quitter ta dernière équipe", | ||
| 449 | + err_password_short: "Le nouveau mot de passe doit faire au moins 8 caractères.", | ||
| 450 | + err_password_mismatch: "La confirmation ne correspond pas.", | ||
| 451 | + err_password_wrong: "Mot de passe actuel incorrect.", | ||
| 452 | + invite_unknown_pat: "Aucun compte « {} ».", | ||
| 453 | + already_member_pat: "{} fait déjà partie de l'équipe.", | ||
| 454 | + already_invited_pat: "Une invitation est déjà en attente pour {}.", | ||
| 455 | + title_todos: "Tâches", | ||
| 456 | + new_todo: "+ Nouvelle tâche", | ||
| 457 | + todos_empty_title: "Aucune tâche pour l'instant", | ||
| 458 | + todos_empty_body_pat: "Ajoute ce qu'il y a à faire dans {} : chacun pourra la prendre, la faire, et le temps passé ira dans les stats.", | ||
| 459 | + doing_block: "En cours", | ||
| 460 | + todo_block: "À faire", | ||
| 461 | + my_tasks: "Mes tâches", | ||
| 462 | + doing_filter_empty: "Aucune tâche en cours ne t'est assignée.", | ||
| 463 | + open_filter_empty: "Aucune tâche à faire ne t'est assignée.", | ||
| 464 | + upcoming: "À venir", | ||
| 465 | + take: "Prendre", | ||
| 466 | + done_check: "Fait ✓", | ||
| 467 | + release: "Relâcher", | ||
| 468 | + edit_todo_aria_pat: "Modifier la tâche {}", | ||
| 469 | + taken_by_pat: "pris par {} · {}", | ||
| 470 | + since_pat: "depuis le {}", | ||
| 471 | + activated_today: "activée aujourd'hui", | ||
| 472 | + from_pat: "à partir du {}", | ||
| 473 | + back_todos: "← Tâches", | ||
| 474 | + title_new_todo: "Nouvelle tâche", | ||
| 475 | + title_edit_todo: "Modifier la tâche", | ||
| 476 | + todo_name_label: "Nom de la tâche", | ||
| 477 | + todo_name_ph: "Nettoyer la salle de bain", | ||
| 478 | + activation_date: "Date d'activation", | ||
| 479 | + assign_to: "Assigner à", | ||
| 480 | + assign_note: "Facultatif. Les autres membres peuvent quand même prendre la tâche.", | ||
| 481 | + you_suffix: " (toi)", | ||
| 482 | + create_todo: "Créer la tâche", | ||
| 483 | + delete_todo: "Supprimer la tâche", | ||
| 484 | + err_todo_incomplete: "Nom, catégorie et date sont requis.", | ||
| 485 | + title_todo_done: "Tâche effectuée", | ||
| 486 | + duration_spent: "Durée passée", | ||
| 487 | + record_time: "Enregistrer un temps", | ||
| 488 | + time_date: "Date du temps", | ||
| 489 | + reschedule: "Reprogrammer", | ||
| 490 | + next_activation_aria: "Prochaine activation", | ||
| 491 | + minutes_spent_aria: "Minutes passées", | ||
| 492 | + validate: "Valider", | ||
| 493 | + rhythm_every_pat: "Rythme constaté : tous les ~{} jours", | ||
| 494 | + rhythm_every_one: "Rythme constaté : environ tous les jours", | ||
| 495 | + rhythm_none_pat: "Pas encore de rythme constaté — proposition à {} jours.", | ||
| 496 | + toast_todo_created: "Tâche créée ✓", | ||
| 497 | + toast_todo_edited: "Tâche modifiée ✓", | ||
| 498 | + toast_todo_deleted: "Tâche supprimée", | ||
| 499 | + toast_todo_taken: "Tâche prise ✓", | ||
| 500 | + toast_todo_released: "Tâche relâchée", | ||
| 501 | + toast_todo_done: "Tâche faite ✓", | ||
| 502 | + toast_todo_done_reprog: "Faite et reprogrammée ✓", | ||
| 503 | + err_404: "Page introuvable.", | ||
| 504 | + err_csrf: "Formulaire expiré, veuillez réessayer.", | ||
| 505 | + err_500: "Erreur interne.", | ||
| 506 | + back_home: "← Accueil", | ||
| 507 | + weekdays: ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"], | ||
| 508 | + months: [ | ||
| 509 | + "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", | ||
| 510 | + "septembre", "octobre", "novembre", "décembre", | ||
| 511 | + ], | ||
| 512 | + today_suffix: " — aujourd'hui", | ||
| 513 | + week_range_pat: "Semaine du {} au {}", | ||
| 514 | + year_pat: "Année {}", | ||
| 515 | +}; | ||
| 516 | + | ||
| 517 | +pub static EN: Tr = Tr { | ||
| 518 | + tab_add: "Add", | ||
| 519 | + tab_stats: "Stats", | ||
| 520 | + tab_todos: "Tasks", | ||
| 521 | + tab_settings: "Settings", | ||
| 522 | + change_team: "Switch team", | ||
| 523 | + close: "Close", | ||
| 524 | + member_one: "member", | ||
| 525 | + member_many: "members", | ||
| 526 | + start_timer: "Start the timer", | ||
| 527 | + start_timer_aria: "Start timer", | ||
| 528 | + stop_timer_aria: "Stop timer", | ||
| 529 | + timer_running_note: "running", | ||
| 530 | + record_measured_aria: "Record the measured time", | ||
| 531 | + record_measured_switch_aria: "Record the measured time (switches team)", | ||
| 532 | + measured_pat: "{} measured", | ||
| 533 | + in_team_pat: "in team {}", | ||
| 534 | + timer_in_team_pat: "timer in team {}", | ||
| 535 | + login_title: "Sign in", | ||
| 536 | + tagline: "Time spent on each category, shared with your team.", | ||
| 537 | + username_label: "Username", | ||
| 538 | + password_label: "Password", | ||
| 539 | + sign_in: "Sign in", | ||
| 540 | + bad_credentials: "Incorrect credentials.", | ||
| 541 | + title_add: "Add time", | ||
| 542 | + step1_hint: "Step 1 of 2 — pick the category", | ||
| 543 | + no_categories_yet: "No categories yet — start by creating one.", | ||
| 544 | + manage_categories: "Manage categories", | ||
| 545 | + change_category: "← Change category", | ||
| 546 | + duration_label: "Duration", | ||
| 547 | + minutes_unit: "minutes", | ||
| 548 | + date_label: "Date", | ||
| 549 | + five_less: "Five minutes less", | ||
| 550 | + five_more: "Five minutes more", | ||
| 551 | + day_prev: "Previous day", | ||
| 552 | + day_next: "Next day", | ||
| 553 | + forgot_timer: "Forgot the timer? You can fix the duration in the next step.", | ||
| 554 | + cancel_measured: "Discard this time", | ||
| 555 | + measured_pick_pat: "{} measured — pick the category", | ||
| 556 | + week_hours_pat: "{} this week", | ||
| 557 | + added_pat: "{} added ✓", | ||
| 558 | + err_duration_min: "The duration must be at least 1 minute.", | ||
| 559 | + err_cat_team: "This category does not belong to the active team.", | ||
| 560 | + title_timer: "Timer", | ||
| 561 | + chrono_kicker: "Timer running", | ||
| 562 | + started_at_pat: "Started at {}", | ||
| 563 | + chrono_hint: "Finish the timer to pick a category and record this time.", | ||
| 564 | + finish_timer: "Finish the timer", | ||
| 565 | + cancel_timer: "Cancel the timer", | ||
| 566 | + toast_measure_discarded: "Measured time discarded", | ||
| 567 | + toast_timer_cancelled: "Timer cancelled", | ||
| 568 | + title_stats: "Statistics", | ||
| 569 | + my_entries_arrow: "My entries →", | ||
| 570 | + this_week: "This week", | ||
| 571 | + this_month: "This month", | ||
| 572 | + this_year: "This year", | ||
| 573 | + all_time: "All time", | ||
| 574 | + no_time_period: "No time recorded in this period.", | ||
| 575 | + all_history: "Entire history", | ||
| 576 | + prev_period: "Previous period", | ||
| 577 | + next_period: "Next period", | ||
| 578 | + share_of_time: "Share of their time", | ||
| 579 | + detail: "Detail", | ||
| 580 | + entry_one: "entry", | ||
| 581 | + entry_many: "entries", | ||
| 582 | + title_times: "My time", | ||
| 583 | + times_empty: "You haven't recorded any time in this team yet.", | ||
| 584 | + see_more: "Show more", | ||
| 585 | + edit_aria: "Edit", | ||
| 586 | + title_edit: "Edit time", | ||
| 587 | + back_times: "← My time", | ||
| 588 | + category_label: "Category", | ||
| 589 | + duration_min_label: "Duration (min)", | ||
| 590 | + save: "Save", | ||
| 591 | + delete: "Delete", | ||
| 592 | + archived_suffix_pat: "{} (archived)", | ||
| 593 | + toast_time_edited: "Time updated", | ||
| 594 | + toast_time_deleted: "Time deleted", | ||
| 595 | + title_categories: "Categories", | ||
| 596 | + categories_hint_pat: "Categories belong to the team {} and can be edited by all its members. Archiving a category removes it from lists; recorded times stay in the stats.", | ||
| 597 | + new_category_ph: "New category", | ||
| 598 | + add: "Add", | ||
| 599 | + rename: "Rename", | ||
| 600 | + rename_cat_aria: "Rename the category (press Enter to confirm)", | ||
| 601 | + restore: "Restore", | ||
| 602 | + archive_aria: "Archive", | ||
| 603 | + rename_help: "Rename: edit the name, then press Enter.", | ||
| 604 | + back_add: "← Back to add", | ||
| 605 | + toast_cat_added: "Category added", | ||
| 606 | + toast_cat_renamed: "Category renamed", | ||
| 607 | + toast_cat_archived: "Category archived — times stay in the stats", | ||
| 608 | + toast_cat_restored: "Category restored", | ||
| 609 | + err_cat_dup: "A category already has this name", | ||
| 610 | + err_cat_empty: "The name cannot be empty", | ||
| 611 | + title_settings: "Settings", | ||
| 612 | + invitation_kicker: "Invitation", | ||
| 613 | + invite_text_pat: "{} invites you to join the team “{}”.", | ||
| 614 | + accept: "Accept", | ||
| 615 | + refuse: "Decline", | ||
| 616 | + account_kicker: "Account", | ||
| 617 | + account_line_pat: "Member of {} teams. Accounts are created from the command line.", | ||
| 618 | + account_line_single: "Accounts are created from the command line.", | ||
| 619 | + my_entries: "My entries", | ||
| 620 | + my_entries_desc: "View and fix the times I recorded.", | ||
| 621 | + team_name_label: "Team name", | ||
| 622 | + rename_team_note: "The new name is visible to all members.", | ||
| 623 | + members_of_pat: "Members of {}", | ||
| 624 | + invite_user_label: "Invite a user", | ||
| 625 | + username_ph: "username", | ||
| 626 | + invite: "Invite", | ||
| 627 | + invite_note: "The user must already have an account. They receive an invitation to accept on their side.", | ||
| 628 | + pending_invites: "Pending invitations", | ||
| 629 | + invited_by_pat: "invited by {} · pending", | ||
| 630 | + cancel: "Cancel", | ||
| 631 | + create_team: "Create a team", | ||
| 632 | + create: "Create", | ||
| 633 | + new_team_aria: "Name of the new team", | ||
| 634 | + create_team_note: "You switch to it right away — then invite whoever you want.", | ||
| 635 | + leave_team_pat: "Leave “{}”", | ||
| 636 | + leave_note: "Your recorded times stay in the team's stats.", | ||
| 637 | + language: "Language", | ||
| 638 | + session: "Session", | ||
| 639 | + current_password: "Current password", | ||
| 640 | + new_password: "New password", | ||
| 641 | + confirmation: "Confirmation", | ||
| 642 | + change_password: "Change password", | ||
| 643 | + sign_out: "Sign out", | ||
| 644 | + signout_note: "The session biscuit will be revoked.", | ||
| 645 | + toast_lang_updated: "Language updated", | ||
| 646 | + toast_password_updated: "Password updated", | ||
| 647 | + toast_invite_sent: "Invitation sent", | ||
| 648 | + toast_invite_refused: "Invitation declined", | ||
| 649 | + toast_invite_cancelled: "Invitation cancelled", | ||
| 650 | + team_created_pat: "Team {} created", | ||
| 651 | + toast_team_renamed: "Team renamed", | ||
| 652 | + toast_joined_pat: "You joined {}", | ||
| 653 | + toast_team_left: "Team left", | ||
| 654 | + err_team_name_taken: "This team name is already taken", | ||
| 655 | + err_team_name_empty: "The team name cannot be empty", | ||
| 656 | + err_last_team: "You cannot leave your last team", | ||
| 657 | + err_password_short: "The new password must be at least 8 characters.", | ||
| 658 | + err_password_mismatch: "The confirmation does not match.", | ||
| 659 | + err_password_wrong: "Current password is incorrect.", | ||
| 660 | + invite_unknown_pat: "No account “{}”.", | ||
| 661 | + already_member_pat: "{} is already a member of the team.", | ||
| 662 | + already_invited_pat: "An invitation is already pending for {}.", | ||
| 663 | + title_todos: "Tasks", | ||
| 664 | + new_todo: "+ New task", | ||
| 665 | + todos_empty_title: "No tasks yet", | ||
| 666 | + todos_empty_body_pat: "Add what needs doing in {}: anyone can take it, do it, and the time spent goes into the stats.", | ||
| 667 | + doing_block: "In progress", | ||
| 668 | + todo_block: "To do", | ||
| 669 | + my_tasks: "My tasks", | ||
| 670 | + doing_filter_empty: "No in-progress task is assigned to you.", | ||
| 671 | + open_filter_empty: "No to-do task is assigned to you.", | ||
| 672 | + upcoming: "Upcoming", | ||
| 673 | + take: "Take", | ||
| 674 | + done_check: "Done ✓", | ||
| 675 | + release: "Release", | ||
| 676 | + edit_todo_aria_pat: "Edit task {}", | ||
| 677 | + taken_by_pat: "taken by {} · {}", | ||
| 678 | + since_pat: "since {}", | ||
| 679 | + activated_today: "active since today", | ||
| 680 | + from_pat: "from {}", | ||
| 681 | + back_todos: "← Tasks", | ||
| 682 | + title_new_todo: "New task", | ||
| 683 | + title_edit_todo: "Edit task", | ||
| 684 | + todo_name_label: "Task name", | ||
| 685 | + todo_name_ph: "Clean the bathroom", | ||
| 686 | + activation_date: "Activation date", | ||
| 687 | + assign_to: "Assign to", | ||
| 688 | + assign_note: "Optional. Other members can still take the task.", | ||
| 689 | + you_suffix: " (you)", | ||
| 690 | + create_todo: "Create the task", | ||
| 691 | + delete_todo: "Delete the task", | ||
| 692 | + err_todo_incomplete: "Name, category and date are required.", | ||
| 693 | + title_todo_done: "Task completed", | ||
| 694 | + duration_spent: "Time spent", | ||
| 695 | + record_time: "Record time", | ||
| 696 | + time_date: "Date of the time", | ||
| 697 | + reschedule: "Reschedule", | ||
| 698 | + next_activation_aria: "Next activation", | ||
| 699 | + minutes_spent_aria: "Minutes spent", | ||
| 700 | + validate: "Confirm", | ||
| 701 | + rhythm_every_pat: "Observed rhythm: about every {} days", | ||
| 702 | + rhythm_every_one: "Observed rhythm: about every day", | ||
| 703 | + rhythm_none_pat: "No rhythm observed yet — proposing {} days.", | ||
| 704 | + toast_todo_created: "Task created ✓", | ||
| 705 | + toast_todo_edited: "Task updated ✓", | ||
| 706 | + toast_todo_deleted: "Task deleted", | ||
| 707 | + toast_todo_taken: "Task taken ✓", | ||
| 708 | + toast_todo_released: "Task released", | ||
| 709 | + toast_todo_done: "Task done ✓", | ||
| 710 | + toast_todo_done_reprog: "Done and rescheduled ✓", | ||
| 711 | + err_404: "Page not found.", | ||
| 712 | + err_csrf: "The form expired, please try again.", | ||
| 713 | + err_500: "Internal error.", | ||
| 714 | + back_home: "← Home", | ||
| 715 | + weekdays: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], | ||
| 716 | + months: [ | ||
| 717 | + "January", "February", "March", "April", "May", "June", "July", "August", | ||
| 718 | + "September", "October", "November", "December", | ||
| 719 | + ], | ||
| 720 | + today_suffix: " — today", | ||
| 721 | + week_range_pat: "Week of {} to {}", | ||
| 722 | + year_pat: "Year {}", | ||
| 723 | +}; | ||
| 724 | + | ||
| 725 | +pub static DE: Tr = Tr { | ||
| 726 | + tab_add: "Erfassen", | ||
| 727 | + tab_stats: "Statistik", | ||
| 728 | + tab_todos: "Aufgaben", | ||
| 729 | + tab_settings: "Optionen", | ||
| 730 | + change_team: "Team wechseln", | ||
| 731 | + close: "Schließen", | ||
| 732 | + member_one: "Mitglied", | ||
| 733 | + member_many: "Mitglieder", | ||
| 734 | + start_timer: "Timer starten", | ||
| 735 | + start_timer_aria: "Timer starten", | ||
| 736 | + stop_timer_aria: "Timer stoppen", | ||
| 737 | + timer_running_note: "läuft", | ||
| 738 | + record_measured_aria: "Gemessene Zeit erfassen", | ||
| 739 | + record_measured_switch_aria: "Gemessene Zeit erfassen (wechselt das Team)", | ||
| 740 | + measured_pat: "{} gemessen", | ||
| 741 | + in_team_pat: "im Team {}", | ||
| 742 | + timer_in_team_pat: "Timer im Team {}", | ||
| 743 | + login_title: "Anmeldung", | ||
| 744 | + tagline: "Die Zeit für jede Kategorie, geteilt mit deinem Team.", | ||
| 745 | + username_label: "Benutzername", | ||
| 746 | + password_label: "Passwort", | ||
| 747 | + sign_in: "Anmelden", | ||
| 748 | + bad_credentials: "Zugangsdaten sind falsch.", | ||
| 749 | + title_add: "Zeit erfassen", | ||
| 750 | + step1_hint: "Schritt 1 von 2 — wähle die Kategorie", | ||
| 751 | + no_categories_yet: "Noch keine Kategorien — lege zuerst eine an.", | ||
| 752 | + manage_categories: "Kategorien verwalten", | ||
| 753 | + change_category: "← Kategorie wechseln", | ||
| 754 | + duration_label: "Dauer", | ||
| 755 | + minutes_unit: "Minuten", | ||
| 756 | + date_label: "Datum", | ||
| 757 | + five_less: "Fünf Minuten weniger", | ||
| 758 | + five_more: "Fünf Minuten mehr", | ||
| 759 | + day_prev: "Vortag", | ||
| 760 | + day_next: "Nächster Tag", | ||
| 761 | + forgot_timer: "Timer vergessen? Du kannst die Dauer im nächsten Schritt anpassen.", | ||
| 762 | + cancel_measured: "Diese Zeit verwerfen", | ||
| 763 | + measured_pick_pat: "{} gemessen — wähle die Kategorie", | ||
| 764 | + week_hours_pat: "{} diese Woche", | ||
| 765 | + added_pat: "{} erfasst ✓", | ||
| 766 | + err_duration_min: "Die Dauer muss mindestens 1 Minute betragen.", | ||
| 767 | + err_cat_team: "Diese Kategorie gehört nicht zum aktiven Team.", | ||
| 768 | + title_timer: "Timer", | ||
| 769 | + chrono_kicker: "Timer läuft", | ||
| 770 | + started_at_pat: "Gestartet um {}", | ||
| 771 | + chrono_hint: "Beende den Timer, um eine Kategorie zu wählen und die Zeit zu erfassen.", | ||
| 772 | + finish_timer: "Timer beenden", | ||
| 773 | + cancel_timer: "Timer abbrechen", | ||
| 774 | + toast_measure_discarded: "Gemessene Zeit verworfen", | ||
| 775 | + toast_timer_cancelled: "Timer abgebrochen", | ||
| 776 | + title_stats: "Statistik", | ||
| 777 | + my_entries_arrow: "Meine Einträge →", | ||
| 778 | + this_week: "Diese Woche", | ||
| 779 | + this_month: "Dieser Monat", | ||
| 780 | + this_year: "Dieses Jahr", | ||
| 781 | + all_time: "Seit Beginn", | ||
| 782 | + no_time_period: "In diesem Zeitraum wurde keine Zeit erfasst.", | ||
| 783 | + all_history: "Gesamter Verlauf", | ||
| 784 | + prev_period: "Vorheriger Zeitraum", | ||
| 785 | + next_period: "Nächster Zeitraum", | ||
| 786 | + share_of_time: "Zeitanteil", | ||
| 787 | + detail: "Detail", | ||
| 788 | + entry_one: "Eintrag", | ||
| 789 | + entry_many: "Einträge", | ||
| 790 | + title_times: "Meine Zeiten", | ||
| 791 | + times_empty: "Du hast in diesem Team noch keine Zeit erfasst.", | ||
| 792 | + see_more: "Mehr anzeigen", | ||
| 793 | + edit_aria: "Bearbeiten", | ||
| 794 | + title_edit: "Zeit bearbeiten", | ||
| 795 | + back_times: "← Meine Zeiten", | ||
| 796 | + category_label: "Kategorie", | ||
| 797 | + duration_min_label: "Dauer (Min.)", | ||
| 798 | + save: "Speichern", | ||
| 799 | + delete: "Löschen", | ||
| 800 | + archived_suffix_pat: "{} (archiviert)", | ||
| 801 | + toast_time_edited: "Zeit aktualisiert", | ||
| 802 | + toast_time_deleted: "Zeit gelöscht", | ||
| 803 | + title_categories: "Kategorien", | ||
| 804 | + categories_hint_pat: "Kategorien gehören zum Team {} und können von allen Mitgliedern bearbeitet werden. Archivieren entfernt eine Kategorie aus den Listen; erfasste Zeiten bleiben in der Statistik.", | ||
| 805 | + new_category_ph: "Neue Kategorie", | ||
| 806 | + add: "Hinzufügen", | ||
| 807 | + rename: "Umbenennen", | ||
| 808 | + rename_cat_aria: "Kategorie umbenennen (Enter zum Bestätigen)", | ||
| 809 | + restore: "Wiederherstellen", | ||
| 810 | + archive_aria: "Archivieren", | ||
| 811 | + rename_help: "Umbenennen: Namen ändern und Enter drücken.", | ||
| 812 | + back_add: "← Zurück zur Erfassung", | ||
| 813 | + toast_cat_added: "Kategorie hinzugefügt", | ||
| 814 | + toast_cat_renamed: "Kategorie umbenannt", | ||
| 815 | + toast_cat_archived: "Kategorie archiviert — Zeiten bleiben in der Statistik", | ||
| 816 | + toast_cat_restored: "Kategorie wiederhergestellt", | ||
| 817 | + err_cat_dup: "Eine Kategorie trägt bereits diesen Namen", | ||
| 818 | + err_cat_empty: "Der Name darf nicht leer sein", | ||
| 819 | + title_settings: "Einstellungen", | ||
| 820 | + invitation_kicker: "Einladung", | ||
| 821 | + invite_text_pat: "{} lädt dich ins Team „{}“ ein.", | ||
| 822 | + accept: "Annehmen", | ||
| 823 | + refuse: "Ablehnen", | ||
| 824 | + account_kicker: "Konto", | ||
| 825 | + account_line_pat: "Mitglied in {} Teams. Konten werden über die Kommandozeile angelegt.", | ||
| 826 | + account_line_single: "Konten werden über die Kommandozeile angelegt.", | ||
| 827 | + my_entries: "Meine Einträge", | ||
| 828 | + my_entries_desc: "Erfasste Zeiten ansehen und korrigieren.", | ||
| 829 | + team_name_label: "Teamname", | ||
| 830 | + rename_team_note: "Der neue Name ist für alle Mitglieder sichtbar.", | ||
| 831 | + members_of_pat: "Mitglieder von {}", | ||
| 832 | + invite_user_label: "Benutzer einladen", | ||
| 833 | + username_ph: "benutzername", | ||
| 834 | + invite: "Einladen", | ||
| 835 | + invite_note: "Der Benutzer braucht bereits ein Konto. Er erhält eine Einladung zum Annehmen.", | ||
| 836 | + pending_invites: "Ausstehende Einladungen", | ||
| 837 | + invited_by_pat: "eingeladen von {} · ausstehend", | ||
| 838 | + cancel: "Abbrechen", | ||
| 839 | + create_team: "Team erstellen", | ||
| 840 | + create: "Erstellen", | ||
| 841 | + new_team_aria: "Name des neuen Teams", | ||
| 842 | + create_team_note: "Du wechselst sofort dorthin — lade danach ein, wen du möchtest.", | ||
| 843 | + leave_team_pat: "„{}“ verlassen", | ||
| 844 | + leave_note: "Deine erfassten Zeiten bleiben in der Team-Statistik.", | ||
| 845 | + language: "Sprache", | ||
| 846 | + session: "Sitzung", | ||
| 847 | + current_password: "Aktuelles Passwort", | ||
| 848 | + new_password: "Neues Passwort", | ||
| 849 | + confirmation: "Bestätigung", | ||
| 850 | + change_password: "Passwort ändern", | ||
| 851 | + sign_out: "Abmelden", | ||
| 852 | + signout_note: "Das Sitzungs-Biscuit wird widerrufen.", | ||
| 853 | + toast_lang_updated: "Sprache aktualisiert", | ||
| 854 | + toast_password_updated: "Passwort aktualisiert", | ||
| 855 | + toast_invite_sent: "Einladung gesendet", | ||
| 856 | + toast_invite_refused: "Einladung abgelehnt", | ||
| 857 | + toast_invite_cancelled: "Einladung zurückgezogen", | ||
| 858 | + team_created_pat: "Team {} erstellt", | ||
| 859 | + toast_team_renamed: "Team umbenannt", | ||
| 860 | + toast_joined_pat: "Du bist {} beigetreten", | ||
| 861 | + toast_team_left: "Team verlassen", | ||
| 862 | + err_team_name_taken: "Dieser Teamname ist bereits vergeben", | ||
| 863 | + err_team_name_empty: "Der Teamname darf nicht leer sein", | ||
| 864 | + err_last_team: "Du kannst dein letztes Team nicht verlassen", | ||
| 865 | + err_password_short: "Das neue Passwort muss mindestens 8 Zeichen lang sein.", | ||
| 866 | + err_password_mismatch: "Die Bestätigung stimmt nicht überein.", | ||
| 867 | + err_password_wrong: "Aktuelles Passwort ist falsch.", | ||
| 868 | + invite_unknown_pat: "Kein Konto „{}“.", | ||
| 869 | + already_member_pat: "{} ist bereits Mitglied des Teams.", | ||
| 870 | + already_invited_pat: "Für {} steht bereits eine Einladung aus.", | ||
| 871 | + title_todos: "Aufgaben", | ||
| 872 | + new_todo: "+ Neue Aufgabe", | ||
| 873 | + todos_empty_title: "Noch keine Aufgaben", | ||
| 874 | + todos_empty_body_pat: "Trage ein, was in {} zu tun ist: jeder kann die Aufgabe übernehmen, erledigen, und die Zeit fließt in die Statistik.", | ||
| 875 | + doing_block: "In Arbeit", | ||
| 876 | + todo_block: "Zu erledigen", | ||
| 877 | + my_tasks: "Meine Aufgaben", | ||
| 878 | + doing_filter_empty: "Dir ist keine laufende Aufgabe zugewiesen.", | ||
| 879 | + open_filter_empty: "Dir ist keine offene Aufgabe zugewiesen.", | ||
| 880 | + upcoming: "Demnächst", | ||
| 881 | + take: "Übernehmen", | ||
| 882 | + done_check: "Erledigt ✓", | ||
| 883 | + release: "Freigeben", | ||
| 884 | + edit_todo_aria_pat: "Aufgabe {} bearbeiten", | ||
| 885 | + taken_by_pat: "übernommen von {} · {}", | ||
| 886 | + since_pat: "seit dem {}", | ||
| 887 | + activated_today: "seit heute aktiv", | ||
| 888 | + from_pat: "ab dem {}", | ||
| 889 | + back_todos: "← Aufgaben", | ||
| 890 | + title_new_todo: "Neue Aufgabe", | ||
| 891 | + title_edit_todo: "Aufgabe bearbeiten", | ||
| 892 | + todo_name_label: "Name der Aufgabe", | ||
| 893 | + todo_name_ph: "Das Bad putzen", | ||
| 894 | + activation_date: "Aktivierungsdatum", | ||
| 895 | + assign_to: "Zuweisen an", | ||
| 896 | + assign_note: "Optional. Andere Mitglieder können die Aufgabe trotzdem übernehmen.", | ||
| 897 | + you_suffix: " (du)", | ||
| 898 | + create_todo: "Aufgabe erstellen", | ||
| 899 | + delete_todo: "Aufgabe löschen", | ||
| 900 | + err_todo_incomplete: "Name, Kategorie und Datum sind erforderlich.", | ||
| 901 | + title_todo_done: "Aufgabe erledigt", | ||
| 902 | + duration_spent: "Aufgewendete Zeit", | ||
| 903 | + record_time: "Zeit erfassen", | ||
| 904 | + time_date: "Datum der Zeit", | ||
| 905 | + reschedule: "Neu planen", | ||
| 906 | + next_activation_aria: "Nächste Aktivierung", | ||
| 907 | + minutes_spent_aria: "Aufgewendete Minuten", | ||
| 908 | + validate: "Bestätigen", | ||
| 909 | + rhythm_every_pat: "Beobachteter Rhythmus: etwa alle {} Tage", | ||
| 910 | + rhythm_every_one: "Beobachteter Rhythmus: etwa täglich", | ||
| 911 | + rhythm_none_pat: "Noch kein Rhythmus erkennbar — Vorschlag: {} Tage.", | ||
| 912 | + toast_todo_created: "Aufgabe erstellt ✓", | ||
| 913 | + toast_todo_edited: "Aufgabe aktualisiert ✓", | ||
| 914 | + toast_todo_deleted: "Aufgabe gelöscht", | ||
| 915 | + toast_todo_taken: "Aufgabe übernommen ✓", | ||
| 916 | + toast_todo_released: "Aufgabe freigegeben", | ||
| 917 | + toast_todo_done: "Aufgabe erledigt ✓", | ||
| 918 | + toast_todo_done_reprog: "Erledigt und neu geplant ✓", | ||
| 919 | + err_404: "Seite nicht gefunden.", | ||
| 920 | + err_csrf: "Das Formular ist abgelaufen, bitte erneut versuchen.", | ||
| 921 | + err_500: "Interner Fehler.", | ||
| 922 | + back_home: "← Startseite", | ||
| 923 | + weekdays: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"], | ||
| 924 | + months: [ | ||
| 925 | + "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", | ||
| 926 | + "September", "Oktober", "November", "Dezember", | ||
| 927 | + ], | ||
| 928 | + today_suffix: " — heute", | ||
| 929 | + week_range_pat: "Woche vom {} bis {}", | ||
| 930 | + year_pat: "Jahr {}", | ||
| 931 | +}; | ||
| 932 | + | ||
| 933 | +#[cfg(test)] | ||
| 934 | +mod tests { | ||
| 935 | + use super::*; | ||
| 936 | + | ||
| 937 | + #[test] | ||
| 938 | + fn patterns_interpolate() { | ||
| 939 | + assert_eq!(p1(FR.since_pat, "3 août"), "depuis le 3 août"); | ||
| 940 | + assert_eq!(p2(EN.invite_text_pat, "alice", "Garden"), "alice invites you to join the team “Garden”."); | ||
| 941 | + assert_eq!(DE.members_count(1), "1 Mitglied"); | ||
| 942 | + assert_eq!(DE.members_count(3), "3 Mitglieder"); | ||
| 943 | + assert_eq!(EN.entries_count(2), "2 entries"); | ||
| 944 | + assert_eq!(FR.account_line(1), FR.account_line_single); | ||
| 945 | + } | ||
| 946 | + | ||
| 947 | + #[test] | ||
| 948 | + fn lang_codes_roundtrip() { | ||
| 949 | + for l in Lang::ALL { | ||
| 950 | + assert_eq!(Lang::from_code(l.code()), Some(l)); | ||
| 951 | + } | ||
| 952 | + assert_eq!(Lang::from_code("es"), None); | ||
| 953 | + } | ||
| 954 | +} | ||
modified
src/main.rs +1 -0 | @@ -6,6 +6,7 @@ mod db; | ||
| 6 | 6 | mod error; |
| 7 | 7 | mod period; |
| 8 | 8 | mod render; |
| 9 | +mod i18n; | |
| 9 | 10 | mod routes; |
| 10 | 11 | mod schedule; |
| 11 | 12 | mod state; |
| @@ -6,6 +6,7 @@ mod db; | |||
| 6 | mod error; | 6 | mod error; |
| 7 | mod period; | 7 | mod period; |
| 8 | mod render; | 8 | mod render; |
| 9 | +mod i18n; | ||
| 9 | mod routes; | 10 | mod routes; |
| 10 | mod schedule; | 11 | mod schedule; |
| 11 | mod state; | 12 | mod state; |
modified
src/period.rs +31 -41 | @@ -3,31 +3,14 @@ use std::fmt; | ||
| 3 | 3 | use chrono::{Datelike, Duration, NaiveDate, Utc, Weekday}; |
| 4 | 4 | use chrono_tz::Europe::Paris; |
| 5 | 5 | |
| 6 | +use crate::i18n::{Lang, p1, p2}; | |
| 7 | + | |
| 6 | 8 | /// Aujourd'hui en Europe/Paris — seule intrusion de la timezone dans l'appli |
| 7 | 9 | /// (les entrées sont stockées en DATE). |
| 8 | 10 | pub fn paris_today() -> NaiveDate { |
| 9 | 11 | Utc::now().with_timezone(&Paris).date_naive() |
| 10 | 12 | } |
| 11 | 13 | |
| 12 | -const WEEKDAY_NAMES: [&str; 7] = [ | |
| 13 | - "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche", | |
| 14 | -]; | |
| 15 | - | |
| 16 | -const MONTH_NAMES: [&str; 12] = [ | |
| 17 | - "janvier", | |
| 18 | - "février", | |
| 19 | - "mars", | |
| 20 | - "avril", | |
| 21 | - "mai", | |
| 22 | - "juin", | |
| 23 | - "juillet", | |
| 24 | - "août", | |
| 25 | - "septembre", | |
| 26 | - "octobre", | |
| 27 | - "novembre", | |
| 28 | - "décembre", | |
| 29 | -]; | |
| 30 | - | |
| 31 | 14 | fn capitalize(s: &str) -> String { |
| 32 | 15 | let mut c = s.chars(); |
| 33 | 16 | match c.next() { |
| @@ -36,21 +19,23 @@ fn capitalize(s: &str) -> String { | ||
| 36 | 19 | } |
| 37 | 20 | } |
| 38 | 21 | |
| 39 | -/// « 3 août » | |
| 40 | -pub fn day_month(date: NaiveDate) -> String { | |
| 41 | - format!( | |
| 42 | - "{} {}", | |
| 43 | - date.day(), | |
| 44 | - MONTH_NAMES[(date.month() - 1) as usize] | |
| 45 | - ) | |
| 22 | +/// « 3 août » / "3 August" / „3. August“ | |
| 23 | +pub fn day_month(lang: Lang, date: NaiveDate) -> String { | |
| 24 | + let month = lang.tr().months[(date.month() - 1) as usize]; | |
| 25 | + match lang { | |
| 26 | + Lang::De => format!("{}. {}", date.day(), month), | |
| 27 | + _ => format!("{} {}", date.day(), month), | |
| 28 | + } | |
| 46 | 29 | } |
| 47 | 30 | |
| 48 | -/// « Mercredi 6 août — aujourd'hui » | |
| 49 | -pub fn day_label(date: NaiveDate) -> String { | |
| 50 | - let weekday = WEEKDAY_NAMES[date.weekday().num_days_from_monday() as usize]; | |
| 51 | - let base = capitalize(&format!("{} {}", weekday, day_month(date))); | |
| 31 | +/// « Mercredi 6 août — aujourd'hui » / "Wednesday 6 August — today" / | |
| 32 | +/// „Mittwoch, 6. August — heute“ | |
| 33 | +pub fn day_label(lang: Lang, date: NaiveDate) -> String { | |
| 34 | + let weekday = lang.tr().weekdays[date.weekday().num_days_from_monday() as usize]; | |
| 35 | + let sep = if lang == Lang::De { ", " } else { " " }; | |
| 36 | + let base = capitalize(&format!("{weekday}{sep}{}", day_month(lang, date))); | |
| 52 | 37 | if date == paris_today() { |
| 53 | - format!("{base} — aujourd'hui") | |
| 38 | + format!("{base}{}", lang.tr().today_suffix) | |
| 54 | 39 | } else { |
| 55 | 40 | base |
| 56 | 41 | } |
| @@ -103,10 +88,10 @@ impl Week { | ||
| 103 | 88 | } |
| 104 | 89 | |
| 105 | 90 | /// « Semaine du 16 mars au 22 mars » |
| 106 | - pub fn range_label(&self) -> String { | |
| 91 | + pub fn range_label(&self, lang: Lang) -> String { | |
| 107 | 92 | let (start, end) = self.bounds(); |
| 108 | 93 | let end = end - Duration::days(1); |
| 109 | - format!("Semaine du {} au {}", day_month(start), day_month(end)) | |
| 94 | + p2(lang.tr().week_range_pat, day_month(lang, start), day_month(lang, end)) | |
| 110 | 95 | } |
| 111 | 96 | } |
| 112 | 97 | |
| @@ -186,10 +171,10 @@ impl Month { | ||
| 186 | 171 | } |
| 187 | 172 | |
| 188 | 173 | /// « Mars 2026 » |
| 189 | - pub fn label(&self) -> String { | |
| 174 | + pub fn label(&self, lang: Lang) -> String { | |
| 190 | 175 | capitalize(&format!( |
| 191 | 176 | "{} {}", |
| 192 | - MONTH_NAMES[(self.month - 1) as usize], | |
| 177 | + lang.tr().months[(self.month - 1) as usize], | |
| 193 | 178 | self.year |
| 194 | 179 | )) |
| 195 | 180 | } |
| @@ -239,8 +224,8 @@ impl Year { | ||
| 239 | 224 | } |
| 240 | 225 | |
| 241 | 226 | /// « Année 2026 » |
| 242 | - pub fn label(&self) -> String { | |
| 243 | - format!("Année {}", self.year) | |
| 227 | + pub fn label(&self, lang: Lang) -> String { | |
| 228 | + p1(lang.tr().year_pat, self.year) | |
| 244 | 229 | } |
| 245 | 230 | } |
| 246 | 231 | |
| @@ -309,7 +294,8 @@ mod tests { | ||
| 309 | 294 | let y = Year::parse("2026").unwrap(); |
| 310 | 295 | assert_eq!(y.bounds(), (d(2026, 1, 1), d(2027, 1, 1))); |
| 311 | 296 | assert_eq!(y.to_string(), "2026"); |
| 312 | - assert_eq!(y.label(), "Année 2026"); | |
| 297 | + assert_eq!(y.label(Lang::Fr), "Année 2026"); | |
| 298 | + assert_eq!(y.label(Lang::En), "Year 2026"); | |
| 313 | 299 | assert_eq!(y.prev().next(), y); |
| 314 | 300 | assert!(Year::parse("abc").is_none()); |
| 315 | 301 | assert!(Year::parse("12026").is_none()); |
| @@ -317,11 +303,15 @@ mod tests { | ||
| 317 | 303 | |
| 318 | 304 | #[test] |
| 319 | 305 | fn labels() { |
| 320 | - assert_eq!(Month::parse("2026-03").unwrap().label(), "Mars 2026"); | |
| 306 | + assert_eq!(Month::parse("2026-03").unwrap().label(Lang::Fr), "Mars 2026"); | |
| 307 | + assert_eq!(Month::parse("2026-03").unwrap().label(Lang::De), "März 2026"); | |
| 321 | 308 | assert_eq!( |
| 322 | - Week::parse("2026-W12").unwrap().range_label(), | |
| 309 | + Week::parse("2026-W12").unwrap().range_label(Lang::Fr), | |
| 323 | 310 | "Semaine du 16 mars au 22 mars" |
| 324 | 311 | ); |
| 325 | - assert_eq!(day_month(d(2026, 8, 3)), "3 août"); | |
| 312 | + assert_eq!(day_month(Lang::Fr, d(2026, 8, 3)), "3 août"); | |
| 313 | + assert_eq!(day_month(Lang::En, d(2026, 8, 3)), "3 August"); | |
| 314 | + assert_eq!(day_month(Lang::De, d(2026, 8, 3)), "3. August"); | |
| 315 | + assert_eq!(day_label(Lang::De, d(2026, 3, 5)), "Donnerstag, 5. März"); | |
| 326 | 316 | } |
| 327 | 317 | } |
| @@ -3,31 +3,14 @@ use std::fmt; | |||
| 3 | use chrono::{Datelike, Duration, NaiveDate, Utc, Weekday}; | 3 | use chrono::{Datelike, Duration, NaiveDate, Utc, Weekday}; |
| 4 | use chrono_tz::Europe::Paris; | 4 | use chrono_tz::Europe::Paris; |
| 5 | 5 | ||
| 6 | +use crate::i18n::{Lang, p1, p2}; | ||
| 7 | + | ||
| 6 | /// Aujourd'hui en Europe/Paris — seule intrusion de la timezone dans l'appli | 8 | /// Aujourd'hui en Europe/Paris — seule intrusion de la timezone dans l'appli |
| 7 | /// (les entrées sont stockées en DATE). | 9 | /// (les entrées sont stockées en DATE). |
| 8 | pub fn paris_today() -> NaiveDate { | 10 | pub fn paris_today() -> NaiveDate { |
| 9 | Utc::now().with_timezone(&Paris).date_naive() | 11 | Utc::now().with_timezone(&Paris).date_naive() |
| 10 | } | 12 | } |
| 11 | 13 | ||
| 12 | -const WEEKDAY_NAMES: [&str; 7] = [ | ||
| 13 | - "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche", | ||
| 14 | -]; | ||
| 15 | - | ||
| 16 | -const MONTH_NAMES: [&str; 12] = [ | ||
| 17 | - "janvier", | ||
| 18 | - "février", | ||
| 19 | - "mars", | ||
| 20 | - "avril", | ||
| 21 | - "mai", | ||
| 22 | - "juin", | ||
| 23 | - "juillet", | ||
| 24 | - "août", | ||
| 25 | - "septembre", | ||
| 26 | - "octobre", | ||
| 27 | - "novembre", | ||
| 28 | - "décembre", | ||
| 29 | -]; | ||
| 30 | - | ||
| 31 | fn capitalize(s: &str) -> String { | 14 | fn capitalize(s: &str) -> String { |
| 32 | let mut c = s.chars(); | 15 | let mut c = s.chars(); |
| 33 | match c.next() { | 16 | match c.next() { |
| @@ -36,21 +19,23 @@ fn capitalize(s: &str) -> String { | |||
| 36 | } | 19 | } |
| 37 | } | 20 | } |
| 38 | 21 | ||
| 39 | -/// « 3 août » | 22 | +/// « 3 août » / "3 August" / „3. August“ |
| 40 | -pub fn day_month(date: NaiveDate) -> String { | 23 | +pub fn day_month(lang: Lang, date: NaiveDate) -> String { |
| 41 | - format!( | 24 | + let month = lang.tr().months[(date.month() - 1) as usize]; |
| 42 | - "{} {}", | 25 | + match lang { |
| 43 | - date.day(), | 26 | + Lang::De => format!("{}. {}", date.day(), month), |
| 44 | - MONTH_NAMES[(date.month() - 1) as usize] | 27 | + _ => format!("{} {}", date.day(), month), |
| 45 | - ) | 28 | + } |
| 46 | } | 29 | } |
| 47 | 30 | ||
| 48 | -/// « Mercredi 6 août — aujourd'hui » | 31 | +/// « Mercredi 6 août — aujourd'hui » / "Wednesday 6 August — today" / |
| 49 | -pub fn day_label(date: NaiveDate) -> String { | 32 | +/// „Mittwoch, 6. August — heute“ |
| 50 | - let weekday = WEEKDAY_NAMES[date.weekday().num_days_from_monday() as usize]; | 33 | +pub fn day_label(lang: Lang, date: NaiveDate) -> String { |
| 51 | - let base = capitalize(&format!("{} {}", weekday, day_month(date))); | 34 | + let weekday = lang.tr().weekdays[date.weekday().num_days_from_monday() as usize]; |
| 35 | + let sep = if lang == Lang::De { ", " } else { " " }; | ||
| 36 | + let base = capitalize(&format!("{weekday}{sep}{}", day_month(lang, date))); | ||
| 52 | if date == paris_today() { | 37 | if date == paris_today() { |
| 53 | - format!("{base} — aujourd'hui") | 38 | + format!("{base}{}", lang.tr().today_suffix) |
| 54 | } else { | 39 | } else { |
| 55 | base | 40 | base |
| 56 | } | 41 | } |
| @@ -103,10 +88,10 @@ impl Week { | |||
| 103 | } | 88 | } |
| 104 | 89 | ||
| 105 | /// « Semaine du 16 mars au 22 mars » | 90 | /// « Semaine du 16 mars au 22 mars » |
| 106 | - pub fn range_label(&self) -> String { | 91 | + pub fn range_label(&self, lang: Lang) -> String { |
| 107 | let (start, end) = self.bounds(); | 92 | let (start, end) = self.bounds(); |
| 108 | let end = end - Duration::days(1); | 93 | let end = end - Duration::days(1); |
| 109 | - format!("Semaine du {} au {}", day_month(start), day_month(end)) | 94 | + p2(lang.tr().week_range_pat, day_month(lang, start), day_month(lang, end)) |
| 110 | } | 95 | } |
| 111 | } | 96 | } |
| 112 | 97 | ||
| @@ -186,10 +171,10 @@ impl Month { | |||
| 186 | } | 171 | } |
| 187 | 172 | ||
| 188 | /// « Mars 2026 » | 173 | /// « Mars 2026 » |
| 189 | - pub fn label(&self) -> String { | 174 | + pub fn label(&self, lang: Lang) -> String { |
| 190 | capitalize(&format!( | 175 | capitalize(&format!( |
| 191 | "{} {}", | 176 | "{} {}", |
| 192 | - MONTH_NAMES[(self.month - 1) as usize], | 177 | + lang.tr().months[(self.month - 1) as usize], |
| 193 | self.year | 178 | self.year |
| 194 | )) | 179 | )) |
| 195 | } | 180 | } |
| @@ -239,8 +224,8 @@ impl Year { | |||
| 239 | } | 224 | } |
| 240 | 225 | ||
| 241 | /// « Année 2026 » | 226 | /// « Année 2026 » |
| 242 | - pub fn label(&self) -> String { | 227 | + pub fn label(&self, lang: Lang) -> String { |
| 243 | - format!("Année {}", self.year) | 228 | + p1(lang.tr().year_pat, self.year) |
| 244 | } | 229 | } |
| 245 | } | 230 | } |
| 246 | 231 | ||
| @@ -309,7 +294,8 @@ mod tests { | |||
| 309 | let y = Year::parse("2026").unwrap(); | 294 | let y = Year::parse("2026").unwrap(); |
| 310 | assert_eq!(y.bounds(), (d(2026, 1, 1), d(2027, 1, 1))); | 295 | assert_eq!(y.bounds(), (d(2026, 1, 1), d(2027, 1, 1))); |
| 311 | assert_eq!(y.to_string(), "2026"); | 296 | assert_eq!(y.to_string(), "2026"); |
| 312 | - assert_eq!(y.label(), "Année 2026"); | 297 | + assert_eq!(y.label(Lang::Fr), "Année 2026"); |
| 298 | + assert_eq!(y.label(Lang::En), "Year 2026"); | ||
| 313 | assert_eq!(y.prev().next(), y); | 299 | assert_eq!(y.prev().next(), y); |
| 314 | assert!(Year::parse("abc").is_none()); | 300 | assert!(Year::parse("abc").is_none()); |
| 315 | assert!(Year::parse("12026").is_none()); | 301 | assert!(Year::parse("12026").is_none()); |
| @@ -317,11 +303,15 @@ mod tests { | |||
| 317 | 303 | ||
| 318 | #[test] | 304 | #[test] |
| 319 | fn labels() { | 305 | fn labels() { |
| 320 | - assert_eq!(Month::parse("2026-03").unwrap().label(), "Mars 2026"); | 306 | + assert_eq!(Month::parse("2026-03").unwrap().label(Lang::Fr), "Mars 2026"); |
| 307 | + assert_eq!(Month::parse("2026-03").unwrap().label(Lang::De), "März 2026"); | ||
| 321 | assert_eq!( | 308 | assert_eq!( |
| 322 | - Week::parse("2026-W12").unwrap().range_label(), | 309 | + Week::parse("2026-W12").unwrap().range_label(Lang::Fr), |
| 323 | "Semaine du 16 mars au 22 mars" | 310 | "Semaine du 16 mars au 22 mars" |
| 324 | ); | 311 | ); |
| 325 | - assert_eq!(day_month(d(2026, 8, 3)), "3 août"); | 312 | + assert_eq!(day_month(Lang::Fr, d(2026, 8, 3)), "3 août"); |
| 313 | + assert_eq!(day_month(Lang::En, d(2026, 8, 3)), "3 August"); | ||
| 314 | + assert_eq!(day_month(Lang::De, d(2026, 8, 3)), "3. August"); | ||
| 315 | + assert_eq!(day_label(Lang::De, d(2026, 3, 5)), "Donnerstag, 5. März"); | ||
| 326 | } | 316 | } |
| 327 | } | 317 | } |
modified
src/render.rs +4 -0 | @@ -45,6 +45,10 @@ pub struct TimerView { | ||
| 45 | 45 | pub struct Shell { |
| 46 | 46 | pub title: String, |
| 47 | 47 | pub username: String, |
| 48 | + /// Code langue de l'utilisateur (attribut lang de <html>). | |
| 49 | + pub lang_code: &'static str, | |
| 50 | + /// Table de traduction de l'utilisateur ({{ shell.tr.* }}). | |
| 51 | + pub tr: &'static crate::i18n::Tr, | |
| 48 | 52 | /// Nom de l'équipe active (pill de l'en-tête). |
| 49 | 53 | pub team_name: String, |
| 50 | 54 | /// Couleur de l'équipe active (teinte de la pill d'en-tête). |
| @@ -45,6 +45,10 @@ pub struct TimerView { | |||
| 45 | pub struct Shell { | 45 | pub struct Shell { |
| 46 | pub title: String, | 46 | pub title: String, |
| 47 | pub username: String, | 47 | pub username: String, |
| 48 | + /// Code langue de l'utilisateur (attribut lang de <html>). | ||
| 49 | + pub lang_code: &'static str, | ||
| 50 | + /// Table de traduction de l'utilisateur ({{ shell.tr.* }}). | ||
| 51 | + pub tr: &'static crate::i18n::Tr, | ||
| 48 | /// Nom de l'équipe active (pill de l'en-tête). | 52 | /// Nom de l'équipe active (pill de l'en-tête). |
| 49 | pub team_name: String, | 53 | pub team_name: String, |
| 50 | /// Couleur de l'équipe active (teinte de la pill d'en-tête). | 54 | /// Couleur de l'équipe active (teinte de la pill d'en-tête). |
modified
src/routes/entries.rs +26 -25 | @@ -13,6 +13,7 @@ use crate::db::entries::{self, EntryRow}; | ||
| 13 | 13 | use crate::db::categories::{self, CategoryRow}; |
| 14 | 14 | use crate::db::timers; |
| 15 | 15 | use crate::error::AppError; |
| 16 | +use crate::i18n::p1; | |
| 16 | 17 | use crate::period::{Week, day_label, paris_today}; |
| 17 | 18 | use crate::render::{HtmlTemplate, Shell}; |
| 18 | 19 | use crate::routes::shell; |
| @@ -54,8 +55,8 @@ pub struct TimerPage { | ||
| 54 | 55 | pub shell: Shell, |
| 55 | 56 | /// « 47 min 23 s » au rendu — le JS prend le relais chaque seconde. |
| 56 | 57 | pub live_label: String, |
| 57 | - /// « 14:32 » (Europe/Paris). | |
| 58 | - pub start_clock: String, | |
| 58 | + /// « Démarré à 14:32 » (Europe/Paris), déjà traduit. | |
| 59 | + pub started_line: String, | |
| 59 | 60 | pub started_ms: i64, |
| 60 | 61 | } |
| 61 | 62 | |
| @@ -111,12 +112,13 @@ pub async fn entry_new( | ||
| 111 | 112 | ctx: ActiveTeam, |
| 112 | 113 | Query(q): Query<NewQuery>, |
| 113 | 114 | ) -> Result<Response, AppError> { |
| 115 | + let tr = ctx.tr(); | |
| 114 | 116 | let toast = if q.annule.is_some() { |
| 115 | - Some("Temps mesuré annulé".to_string()) | |
| 117 | + Some(tr.toast_measure_discarded.to_string()) | |
| 116 | 118 | } else if q.abandonne.is_some() { |
| 117 | - Some("Minuteur annulé".to_string()) | |
| 119 | + Some(tr.toast_timer_cancelled.to_string()) | |
| 118 | 120 | } else { |
| 119 | - q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m))) | |
| 121 | + q.ok.map(|m| p1(tr.added_pat, fmt_minutes(m))) | |
| 120 | 122 | }; |
| 121 | 123 | let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 122 | 124 | let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); |
| @@ -132,13 +134,13 @@ pub async fn entry_new( | ||
| 132 | 134 | .as_ref() |
| 133 | 135 | .filter(|t| t.team_id == ctx.team_id && t.stopped_at.is_none()) |
| 134 | 136 | { |
| 135 | - let mut sh = shell(&state, &ctx, "Ajouter du temps", "add", toast).await?; | |
| 137 | + let mut sh = shell(&state, &ctx, tr.title_add, "add", toast).await?; | |
| 136 | 138 | // L'écran EST le minuteur : pas de bandeau flottant en doublon. |
| 137 | 139 | sh.timer = None; |
| 138 | 140 | return Ok(HtmlTemplate(TimerPage { |
| 139 | 141 | shell: sh, |
| 140 | 142 | live_label: t.live_label(chrono::Utc::now()), |
| 141 | - start_clock: t.start_clock(), | |
| 143 | + started_line: p1(tr.started_at_pat, t.start_clock()), | |
| 142 | 144 | started_ms: t.started_at.timestamp_millis(), |
| 143 | 145 | }) |
| 144 | 146 | .into_response()); |
| @@ -178,7 +180,7 @@ pub async fn entry_new( | ||
| 178 | 180 | selected: minutes == m, |
| 179 | 181 | }) |
| 180 | 182 | .collect(); |
| 181 | - let mut sh = shell(&state, &ctx, "Ajouter du temps", "add", toast).await?; | |
| 183 | + let mut sh = shell(&state, &ctx, tr.title_add, "add", toast).await?; | |
| 182 | 184 | sh.show_start = can_start; |
| 183 | 185 | return Ok(HtmlTemplate(AddStep2Page { |
| 184 | 186 | csrf: ctx.user.csrf_token(&state), |
| @@ -193,7 +195,7 @@ pub async fn entry_new( | ||
| 193 | 195 | plus_href: step2_href(picked.id, minutes.saturating_add(5), date), |
| 194 | 196 | presets, |
| 195 | 197 | date, |
| 196 | - date_label: day_label(date), | |
| 198 | + date_label: day_label(ctx.lang, date), | |
| 197 | 199 | day_prev: step2_href(picked.id, minutes, date - Duration::days(1)), |
| 198 | 200 | day_next: (date < paris_today()) |
| 199 | 201 | .then(|| step2_href(picked.id, minutes, date + Duration::days(1))), |
| @@ -218,15 +220,15 @@ pub async fn entry_new( | ||
| 218 | 220 | name: t.name.clone(), |
| 219 | 221 | week_label: week |
| 220 | 222 | .get(&t.id) |
| 221 | - .map(|m| format!("{} cette semaine", fmt_minutes(*m))) | |
| 223 | + .map(|m| p1(tr.week_hours_pat, fmt_minutes(*m))) | |
| 222 | 224 | .unwrap_or_default(), |
| 223 | 225 | }) |
| 224 | 226 | .collect(); |
| 225 | 227 | let measured = pending.and_then(|t| t.measured_minutes()).map(|m| MeasuredView { |
| 226 | - title: format!("{} mesurées — choisis la catégorie", fmt_minutes(m)), | |
| 228 | + title: p1(tr.measured_pick_pat, fmt_minutes(m)), | |
| 227 | 229 | warning: m > 240, |
| 228 | 230 | }); |
| 229 | - let mut sh = shell(&state, &ctx, "Ajouter du temps", "add", toast).await?; | |
| 231 | + let mut sh = shell(&state, &ctx, tr.title_add, "add", toast).await?; | |
| 230 | 232 | sh.show_start = can_start; |
| 231 | 233 | Ok(HtmlTemplate(AddStep1Page { |
| 232 | 234 | shell: sh, |
| @@ -244,11 +246,9 @@ pub struct EntryForm { | ||
| 244 | 246 | csrf: String, |
| 245 | 247 | } |
| 246 | 248 | |
| 247 | -fn validate(form: &EntryForm) -> Result<(), AppError> { | |
| 249 | +fn validate(form: &EntryForm, tr: &crate::i18n::Tr) -> Result<(), AppError> { | |
| 248 | 250 | if form.minutes < 1 { |
| 249 | - return Err(AppError::BadRequest( | |
| 250 | - "La durée doit être d'au moins 1 minute.".into(), | |
| 251 | - )); | |
| 251 | + return Err(AppError::BadRequest(tr.err_duration_min.into())); | |
| 252 | 252 | } |
| 253 | 253 | Ok(()) |
| 254 | 254 | } |
| @@ -261,7 +261,7 @@ pub async fn entry_create( | ||
| 261 | 261 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 262 | 262 | return Err(AppError::Csrf); |
| 263 | 263 | } |
| 264 | - validate(&form)?; | |
| 264 | + validate(&form, ctx.tr())?; | |
| 265 | 265 | let n = entries::insert( |
| 266 | 266 | &state.pool, |
| 267 | 267 | ctx.user.id, |
| @@ -273,7 +273,7 @@ pub async fn entry_create( | ||
| 273 | 273 | .await?; |
| 274 | 274 | if n == 0 { |
| 275 | 275 | return Err(AppError::BadRequest( |
| 276 | - "Cette catégorie n'appartient pas à l'équipe active.".into(), | |
| 276 | + ctx.tr().err_cat_team.into(), | |
| 277 | 277 | )); |
| 278 | 278 | } |
| 279 | 279 | // Une mesure de timer en attente est consommée par l'enregistrement. |
| @@ -310,9 +310,10 @@ pub async fn entries_list( | ||
| 310 | 310 | ctx: ActiveTeam, |
| 311 | 311 | Query(q): Query<PageQuery>, |
| 312 | 312 | ) -> Result<HtmlTemplate<TimesPage>, AppError> { |
| 313 | + let tr = ctx.tr(); | |
| 313 | 314 | let toast = match q.ok.as_deref() { |
| 314 | - Some("modif") => Some("Temps modifié".to_string()), | |
| 315 | - Some("suppr") => Some("Temps supprimé".to_string()), | |
| 315 | + Some("modif") => Some(tr.toast_time_edited.to_string()), | |
| 316 | + Some("suppr") => Some(tr.toast_time_deleted.to_string()), | |
| 316 | 317 | _ => None, |
| 317 | 318 | }; |
| 318 | 319 | let page = q.page.unwrap_or(1).max(1); |
| @@ -327,13 +328,13 @@ pub async fn entries_list( | ||
| 327 | 328 | .map(|e: EntryRow| TimeView { |
| 328 | 329 | color: colors.get(&e.category_id).copied().unwrap_or("#a19786"), |
| 329 | 330 | category: e.category_name, |
| 330 | - date: day_label(e.entry_date), | |
| 331 | + date: day_label(ctx.lang, e.entry_date), | |
| 331 | 332 | duration: fmt_minutes(e.minutes as i64), |
| 332 | 333 | edit_href: format!("/temps/{}/modifier", e.id), |
| 333 | 334 | }) |
| 334 | 335 | .collect(); |
| 335 | 336 | Ok(HtmlTemplate(TimesPage { |
| 336 | - shell: shell(&state, &ctx, "Mes temps", "times", toast).await?, | |
| 337 | + shell: shell(&state, &ctx, tr.title_times, "times", toast).await?, | |
| 337 | 338 | entries, |
| 338 | 339 | more_href: has_more.then(|| format!("/temps?page={}", page + 1)), |
| 339 | 340 | })) |
| @@ -375,7 +376,7 @@ pub async fn entry_edit( | ||
| 375 | 376 | .map(|t| CategoryOption { |
| 376 | 377 | selected: t.id == entry.category_id, |
| 377 | 378 | name: if t.archived { |
| 378 | - format!("{} (archivée)", t.name) | |
| 379 | + p1(ctx.tr().archived_suffix_pat, &t.name) | |
| 379 | 380 | } else { |
| 380 | 381 | t.name |
| 381 | 382 | }, |
| @@ -385,7 +386,7 @@ pub async fn entry_edit( | ||
| 385 | 386 | options.sort_by(|a, b| a.name.cmp(&b.name)); |
| 386 | 387 | Ok(HtmlTemplate(EditPage { |
| 387 | 388 | csrf: ctx.user.csrf_token(&state), |
| 388 | - shell: shell(&state, &ctx, "Modifier le temps", "times", None).await?, | |
| 389 | + shell: shell(&state, &ctx, ctx.tr().title_edit, "times", None).await?, | |
| 389 | 390 | action: format!("/temps/{id}"), |
| 390 | 391 | delete_action: format!("/temps/{id}/supprimer"), |
| 391 | 392 | categories: options, |
| @@ -403,7 +404,7 @@ pub async fn entry_update( | ||
| 403 | 404 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 404 | 405 | return Err(AppError::Csrf); |
| 405 | 406 | } |
| 406 | - validate(&form)?; | |
| 407 | + validate(&form, ctx.tr())?; | |
| 407 | 408 | let n = entries::update_owned( |
| 408 | 409 | &state.pool, |
| 409 | 410 | id, |
| @@ -13,6 +13,7 @@ use crate::db::entries::{self, EntryRow}; | |||
| 13 | use crate::db::categories::{self, CategoryRow}; | 13 | use crate::db::categories::{self, CategoryRow}; |
| 14 | use crate::db::timers; | 14 | use crate::db::timers; |
| 15 | use crate::error::AppError; | 15 | use crate::error::AppError; |
| 16 | +use crate::i18n::p1; | ||
| 16 | use crate::period::{Week, day_label, paris_today}; | 17 | use crate::period::{Week, day_label, paris_today}; |
| 17 | use crate::render::{HtmlTemplate, Shell}; | 18 | use crate::render::{HtmlTemplate, Shell}; |
| 18 | use crate::routes::shell; | 19 | use crate::routes::shell; |
| @@ -54,8 +55,8 @@ pub struct TimerPage { | |||
| 54 | pub shell: Shell, | 55 | pub shell: Shell, |
| 55 | /// « 47 min 23 s » au rendu — le JS prend le relais chaque seconde. | 56 | /// « 47 min 23 s » au rendu — le JS prend le relais chaque seconde. |
| 56 | pub live_label: String, | 57 | pub live_label: String, |
| 57 | - /// « 14:32 » (Europe/Paris). | 58 | + /// « Démarré à 14:32 » (Europe/Paris), déjà traduit. |
| 58 | - pub start_clock: String, | 59 | + pub started_line: String, |
| 59 | pub started_ms: i64, | 60 | pub started_ms: i64, |
| 60 | } | 61 | } |
| 61 | 62 | ||
| @@ -111,12 +112,13 @@ pub async fn entry_new( | |||
| 111 | ctx: ActiveTeam, | 112 | ctx: ActiveTeam, |
| 112 | Query(q): Query<NewQuery>, | 113 | Query(q): Query<NewQuery>, |
| 113 | ) -> Result<Response, AppError> { | 114 | ) -> Result<Response, AppError> { |
| 115 | + let tr = ctx.tr(); | ||
| 114 | let toast = if q.annule.is_some() { | 116 | let toast = if q.annule.is_some() { |
| 115 | - Some("Temps mesuré annulé".to_string()) | 117 | + Some(tr.toast_measure_discarded.to_string()) |
| 116 | } else if q.abandonne.is_some() { | 118 | } else if q.abandonne.is_some() { |
| 117 | - Some("Minuteur annulé".to_string()) | 119 | + Some(tr.toast_timer_cancelled.to_string()) |
| 118 | } else { | 120 | } else { |
| 119 | - q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m))) | 121 | + q.ok.map(|m| p1(tr.added_pat, fmt_minutes(m))) |
| 120 | }; | 122 | }; |
| 121 | let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?; | 123 | let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 122 | let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); | 124 | let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); |
| @@ -132,13 +134,13 @@ pub async fn entry_new( | |||
| 132 | .as_ref() | 134 | .as_ref() |
| 133 | .filter(|t| t.team_id == ctx.team_id && t.stopped_at.is_none()) | 135 | .filter(|t| t.team_id == ctx.team_id && t.stopped_at.is_none()) |
| 134 | { | 136 | { |
| 135 | - let mut sh = shell(&state, &ctx, "Ajouter du temps", "add", toast).await?; | 137 | + let mut sh = shell(&state, &ctx, tr.title_add, "add", toast).await?; |
| 136 | // L'écran EST le minuteur : pas de bandeau flottant en doublon. | 138 | // L'écran EST le minuteur : pas de bandeau flottant en doublon. |
| 137 | sh.timer = None; | 139 | sh.timer = None; |
| 138 | return Ok(HtmlTemplate(TimerPage { | 140 | return Ok(HtmlTemplate(TimerPage { |
| 139 | shell: sh, | 141 | shell: sh, |
| 140 | live_label: t.live_label(chrono::Utc::now()), | 142 | live_label: t.live_label(chrono::Utc::now()), |
| 141 | - start_clock: t.start_clock(), | 143 | + started_line: p1(tr.started_at_pat, t.start_clock()), |
| 142 | started_ms: t.started_at.timestamp_millis(), | 144 | started_ms: t.started_at.timestamp_millis(), |
| 143 | }) | 145 | }) |
| 144 | .into_response()); | 146 | .into_response()); |
| @@ -178,7 +180,7 @@ pub async fn entry_new( | |||
| 178 | selected: minutes == m, | 180 | selected: minutes == m, |
| 179 | }) | 181 | }) |
| 180 | .collect(); | 182 | .collect(); |
| 181 | - let mut sh = shell(&state, &ctx, "Ajouter du temps", "add", toast).await?; | 183 | + let mut sh = shell(&state, &ctx, tr.title_add, "add", toast).await?; |
| 182 | sh.show_start = can_start; | 184 | sh.show_start = can_start; |
| 183 | return Ok(HtmlTemplate(AddStep2Page { | 185 | return Ok(HtmlTemplate(AddStep2Page { |
| 184 | csrf: ctx.user.csrf_token(&state), | 186 | csrf: ctx.user.csrf_token(&state), |
| @@ -193,7 +195,7 @@ pub async fn entry_new( | |||
| 193 | plus_href: step2_href(picked.id, minutes.saturating_add(5), date), | 195 | plus_href: step2_href(picked.id, minutes.saturating_add(5), date), |
| 194 | presets, | 196 | presets, |
| 195 | date, | 197 | date, |
| 196 | - date_label: day_label(date), | 198 | + date_label: day_label(ctx.lang, date), |
| 197 | day_prev: step2_href(picked.id, minutes, date - Duration::days(1)), | 199 | day_prev: step2_href(picked.id, minutes, date - Duration::days(1)), |
| 198 | day_next: (date < paris_today()) | 200 | day_next: (date < paris_today()) |
| 199 | .then(|| step2_href(picked.id, minutes, date + Duration::days(1))), | 201 | .then(|| step2_href(picked.id, minutes, date + Duration::days(1))), |
| @@ -218,15 +220,15 @@ pub async fn entry_new( | |||
| 218 | name: t.name.clone(), | 220 | name: t.name.clone(), |
| 219 | week_label: week | 221 | week_label: week |
| 220 | .get(&t.id) | 222 | .get(&t.id) |
| 221 | - .map(|m| format!("{} cette semaine", fmt_minutes(*m))) | 223 | + .map(|m| p1(tr.week_hours_pat, fmt_minutes(*m))) |
| 222 | .unwrap_or_default(), | 224 | .unwrap_or_default(), |
| 223 | }) | 225 | }) |
| 224 | .collect(); | 226 | .collect(); |
| 225 | let measured = pending.and_then(|t| t.measured_minutes()).map(|m| MeasuredView { | 227 | let measured = pending.and_then(|t| t.measured_minutes()).map(|m| MeasuredView { |
| 226 | - title: format!("{} mesurées — choisis la catégorie", fmt_minutes(m)), | 228 | + title: p1(tr.measured_pick_pat, fmt_minutes(m)), |
| 227 | warning: m > 240, | 229 | warning: m > 240, |
| 228 | }); | 230 | }); |
| 229 | - let mut sh = shell(&state, &ctx, "Ajouter du temps", "add", toast).await?; | 231 | + let mut sh = shell(&state, &ctx, tr.title_add, "add", toast).await?; |
| 230 | sh.show_start = can_start; | 232 | sh.show_start = can_start; |
| 231 | Ok(HtmlTemplate(AddStep1Page { | 233 | Ok(HtmlTemplate(AddStep1Page { |
| 232 | shell: sh, | 234 | shell: sh, |
| @@ -244,11 +246,9 @@ pub struct EntryForm { | |||
| 244 | csrf: String, | 246 | csrf: String, |
| 245 | } | 247 | } |
| 246 | 248 | ||
| 247 | -fn validate(form: &EntryForm) -> Result<(), AppError> { | 249 | +fn validate(form: &EntryForm, tr: &crate::i18n::Tr) -> Result<(), AppError> { |
| 248 | if form.minutes < 1 { | 250 | if form.minutes < 1 { |
| 249 | - return Err(AppError::BadRequest( | 251 | + return Err(AppError::BadRequest(tr.err_duration_min.into())); |
| 250 | - "La durée doit être d'au moins 1 minute.".into(), | ||
| 251 | - )); | ||
| 252 | } | 252 | } |
| 253 | Ok(()) | 253 | Ok(()) |
| 254 | } | 254 | } |
| @@ -261,7 +261,7 @@ pub async fn entry_create( | |||
| 261 | if !ctx.user.check_csrf(&state, &form.csrf) { | 261 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 262 | return Err(AppError::Csrf); | 262 | return Err(AppError::Csrf); |
| 263 | } | 263 | } |
| 264 | - validate(&form)?; | 264 | + validate(&form, ctx.tr())?; |
| 265 | let n = entries::insert( | 265 | let n = entries::insert( |
| 266 | &state.pool, | 266 | &state.pool, |
| 267 | ctx.user.id, | 267 | ctx.user.id, |
| @@ -273,7 +273,7 @@ pub async fn entry_create( | |||
| 273 | .await?; | 273 | .await?; |
| 274 | if n == 0 { | 274 | if n == 0 { |
| 275 | return Err(AppError::BadRequest( | 275 | return Err(AppError::BadRequest( |
| 276 | - "Cette catégorie n'appartient pas à l'équipe active.".into(), | 276 | + ctx.tr().err_cat_team.into(), |
| 277 | )); | 277 | )); |
| 278 | } | 278 | } |
| 279 | // Une mesure de timer en attente est consommée par l'enregistrement. | 279 | // Une mesure de timer en attente est consommée par l'enregistrement. |
| @@ -310,9 +310,10 @@ pub async fn entries_list( | |||
| 310 | ctx: ActiveTeam, | 310 | ctx: ActiveTeam, |
| 311 | Query(q): Query<PageQuery>, | 311 | Query(q): Query<PageQuery>, |
| 312 | ) -> Result<HtmlTemplate<TimesPage>, AppError> { | 312 | ) -> Result<HtmlTemplate<TimesPage>, AppError> { |
| 313 | + let tr = ctx.tr(); | ||
| 313 | let toast = match q.ok.as_deref() { | 314 | let toast = match q.ok.as_deref() { |
| 314 | - Some("modif") => Some("Temps modifié".to_string()), | 315 | + Some("modif") => Some(tr.toast_time_edited.to_string()), |
| 315 | - Some("suppr") => Some("Temps supprimé".to_string()), | 316 | + Some("suppr") => Some(tr.toast_time_deleted.to_string()), |
| 316 | _ => None, | 317 | _ => None, |
| 317 | }; | 318 | }; |
| 318 | let page = q.page.unwrap_or(1).max(1); | 319 | let page = q.page.unwrap_or(1).max(1); |
| @@ -327,13 +328,13 @@ pub async fn entries_list( | |||
| 327 | .map(|e: EntryRow| TimeView { | 328 | .map(|e: EntryRow| TimeView { |
| 328 | color: colors.get(&e.category_id).copied().unwrap_or("#a19786"), | 329 | color: colors.get(&e.category_id).copied().unwrap_or("#a19786"), |
| 329 | category: e.category_name, | 330 | category: e.category_name, |
| 330 | - date: day_label(e.entry_date), | 331 | + date: day_label(ctx.lang, e.entry_date), |
| 331 | duration: fmt_minutes(e.minutes as i64), | 332 | duration: fmt_minutes(e.minutes as i64), |
| 332 | edit_href: format!("/temps/{}/modifier", e.id), | 333 | edit_href: format!("/temps/{}/modifier", e.id), |
| 333 | }) | 334 | }) |
| 334 | .collect(); | 335 | .collect(); |
| 335 | Ok(HtmlTemplate(TimesPage { | 336 | Ok(HtmlTemplate(TimesPage { |
| 336 | - shell: shell(&state, &ctx, "Mes temps", "times", toast).await?, | 337 | + shell: shell(&state, &ctx, tr.title_times, "times", toast).await?, |
| 337 | entries, | 338 | entries, |
| 338 | more_href: has_more.then(|| format!("/temps?page={}", page + 1)), | 339 | more_href: has_more.then(|| format!("/temps?page={}", page + 1)), |
| 339 | })) | 340 | })) |
| @@ -375,7 +376,7 @@ pub async fn entry_edit( | |||
| 375 | .map(|t| CategoryOption { | 376 | .map(|t| CategoryOption { |
| 376 | selected: t.id == entry.category_id, | 377 | selected: t.id == entry.category_id, |
| 377 | name: if t.archived { | 378 | name: if t.archived { |
| 378 | - format!("{} (archivée)", t.name) | 379 | + p1(ctx.tr().archived_suffix_pat, &t.name) |
| 379 | } else { | 380 | } else { |
| 380 | t.name | 381 | t.name |
| 381 | }, | 382 | }, |
| @@ -385,7 +386,7 @@ pub async fn entry_edit( | |||
| 385 | options.sort_by(|a, b| a.name.cmp(&b.name)); | 386 | options.sort_by(|a, b| a.name.cmp(&b.name)); |
| 386 | Ok(HtmlTemplate(EditPage { | 387 | Ok(HtmlTemplate(EditPage { |
| 387 | csrf: ctx.user.csrf_token(&state), | 388 | csrf: ctx.user.csrf_token(&state), |
| 388 | - shell: shell(&state, &ctx, "Modifier le temps", "times", None).await?, | 389 | + shell: shell(&state, &ctx, ctx.tr().title_edit, "times", None).await?, |
| 389 | action: format!("/temps/{id}"), | 390 | action: format!("/temps/{id}"), |
| 390 | delete_action: format!("/temps/{id}/supprimer"), | 391 | delete_action: format!("/temps/{id}/supprimer"), |
| 391 | categories: options, | 392 | categories: options, |
| @@ -403,7 +404,7 @@ pub async fn entry_update( | |||
| 403 | if !ctx.user.check_csrf(&state, &form.csrf) { | 404 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 404 | return Err(AppError::Csrf); | 405 | return Err(AppError::Csrf); |
| 405 | } | 406 | } |
| 406 | - validate(&form)?; | 407 | + validate(&form, ctx.tr())?; |
| 407 | let n = entries::update_owned( | 408 | let n = entries::update_owned( |
| 408 | &state.pool, | 409 | &state.pool, |
| 409 | id, | 410 | id, |
modified
src/routes/login.rs +6 -0 | @@ -17,6 +17,9 @@ use crate::state::AppState; | ||
| 17 | 17 | #[template(path = "login.html")] |
| 18 | 18 | pub struct LoginPage { |
| 19 | 19 | pub error: bool, |
| 20 | + /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG). | |
| 21 | + pub tr: &'static crate::i18n::Tr, | |
| 22 | + pub lang_code: &'static str, | |
| 20 | 23 | } |
| 21 | 24 | |
| 22 | 25 | #[derive(Deserialize)] |
| @@ -29,8 +32,11 @@ pub async fn login_page(user: Option<AuthUser>, Query(q): Query<LoginQuery>) -> | ||
| 29 | 32 | if user.is_some() { |
| 30 | 33 | return Redirect::to("/").into_response(); |
| 31 | 34 | } |
| 35 | + let lang = crate::config::default_lang(); | |
| 32 | 36 | HtmlTemplate(LoginPage { |
| 33 | 37 | error: q.err.is_some(), |
| 38 | + tr: lang.tr(), | |
| 39 | + lang_code: lang.code(), | |
| 34 | 40 | }) |
| 35 | 41 | .into_response() |
| 36 | 42 | } |
| @@ -17,6 +17,9 @@ use crate::state::AppState; | |||
| 17 | #[template(path = "login.html")] | 17 | #[template(path = "login.html")] |
| 18 | pub struct LoginPage { | 18 | pub struct LoginPage { |
| 19 | pub error: bool, | 19 | pub error: bool, |
| 20 | + /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG). | ||
| 21 | + pub tr: &'static crate::i18n::Tr, | ||
| 22 | + pub lang_code: &'static str, | ||
| 20 | } | 23 | } |
| 21 | 24 | ||
| 22 | #[derive(Deserialize)] | 25 | #[derive(Deserialize)] |
| @@ -29,8 +32,11 @@ pub async fn login_page(user: Option<AuthUser>, Query(q): Query<LoginQuery>) -> | |||
| 29 | if user.is_some() { | 32 | if user.is_some() { |
| 30 | return Redirect::to("/").into_response(); | 33 | return Redirect::to("/").into_response(); |
| 31 | } | 34 | } |
| 35 | + let lang = crate::config::default_lang(); | ||
| 32 | HtmlTemplate(LoginPage { | 36 | HtmlTemplate(LoginPage { |
| 33 | error: q.err.is_some(), | 37 | error: q.err.is_some(), |
| 38 | + tr: lang.tr(), | ||
| 39 | + lang_code: lang.code(), | ||
| 34 | }) | 40 | }) |
| 35 | .into_response() | 41 | .into_response() |
| 36 | } | 42 | } |
modified
src/routes/mod.rs +11 -10 | @@ -16,7 +16,7 @@ use axum::routing::{get, post}; | ||
| 16 | 16 | |
| 17 | 17 | use crate::auth::extractor::AuthUser; |
| 18 | 18 | use crate::charts::{fmt_minutes, team_color}; |
| 19 | -use crate::db::{invitations, teams as teams_db, timers, todos as todos_db, users}; | |
| 19 | +use crate::db::{invitations, teams as teams_db, timers, todos as todos_db}; | |
| 20 | 20 | use crate::error::AppError; |
| 21 | 21 | use crate::render::{Shell, TeamChoiceView, TimerView}; |
| 22 | 22 | use crate::state::AppState; |
| @@ -47,6 +47,7 @@ pub fn router(state: AppState) -> Router { | ||
| 47 | 47 | "/parametres/mot-de-passe", |
| 48 | 48 | post(settings::password_change), |
| 49 | 49 | ) |
| 50 | + .route("/parametres/langue", post(settings::lang_change)) | |
| 50 | 51 | .route("/taches", get(todos::todos_page).post(todos::todo_create)) |
| 51 | 52 | .route("/taches/nouvelle", get(todos::todo_new)) |
| 52 | 53 | .route("/taches/{id}/modifier", get(todos::todo_edit)) |
| @@ -130,12 +131,10 @@ pub async fn shell( | ||
| 130 | 131 | tab: &'static str, |
| 131 | 132 | toast: Option<String>, |
| 132 | 133 | ) -> Result<Shell, AppError> { |
| 133 | - let username = users::username(&state.pool, ctx.user.id) | |
| 134 | - .await? | |
| 135 | - .unwrap_or_default(); | |
| 136 | 134 | let invite_count = invitations::count_for(&state.pool, ctx.user.id).await?; |
| 137 | 135 | let todo_count = |
| 138 | 136 | todos_db::open_count(&state.pool, ctx.team_id, crate::period::paris_today()).await?; |
| 137 | + let tr = ctx.tr(); | |
| 139 | 138 | let timer_row = timers::get(&state.pool, ctx.user.id).await?; |
| 140 | 139 | let badge = timer_row.is_some(); |
| 141 | 140 | let timer = timer_row.map(|t| { |
| @@ -144,9 +143,9 @@ pub async fn shell( | ||
| 144 | 143 | Some(m) => TimerView { |
| 145 | 144 | running: false, |
| 146 | 145 | foreign, |
| 147 | - label: format!("{} mesurées", fmt_minutes(m)), | |
| 146 | + label: crate::i18n::p1(tr.measured_pat, fmt_minutes(m)), | |
| 148 | 147 | note: if foreign { |
| 149 | - format!("dans l’équipe {}", t.team_name) | |
| 148 | + crate::i18n::p1(tr.in_team_pat, &t.team_name) | |
| 150 | 149 | } else { |
| 151 | 150 | String::new() |
| 152 | 151 | }, |
| @@ -157,9 +156,9 @@ pub async fn shell( | ||
| 157 | 156 | foreign, |
| 158 | 157 | label: fmt_minutes(t.elapsed_minutes(chrono::Utc::now())), |
| 159 | 158 | note: if foreign { |
| 160 | - format!("timer dans l’équipe {}", t.team_name) | |
| 159 | + crate::i18n::p1(tr.timer_in_team_pat, &t.team_name) | |
| 161 | 160 | } else { |
| 162 | - "en cours".to_string() | |
| 161 | + tr.timer_running_note.to_string() | |
| 163 | 162 | }, |
| 164 | 163 | started_ms: t.started_at.timestamp_millis(), |
| 165 | 164 | }, |
| @@ -167,7 +166,9 @@ pub async fn shell( | ||
| 167 | 166 | }); |
| 168 | 167 | Ok(Shell { |
| 169 | 168 | title: title.to_string(), |
| 170 | - username, | |
| 169 | + username: ctx.username.clone(), | |
| 170 | + lang_code: ctx.lang.code(), | |
| 171 | + tr, | |
| 171 | 172 | team_name: ctx.team_name.clone(), |
| 172 | 173 | team_color: team_color(ctx.team_id), |
| 173 | 174 | tab, |
| @@ -191,7 +192,7 @@ pub async fn shell( | ||
| 191 | 192 | mine.into_iter() |
| 192 | 193 | .map(|t| TeamChoiceView { |
| 193 | 194 | current: t.id == ctx.team_id, |
| 194 | - meta: format!("{} membre{}", t.members, if t.members > 1 { "s" } else { "" }), | |
| 195 | + meta: tr.members_count(t.members), | |
| 195 | 196 | color: team_color(t.id), |
| 196 | 197 | id: t.id, |
| 197 | 198 | name: t.name, |
| @@ -16,7 +16,7 @@ use axum::routing::{get, post}; | |||
| 16 | 16 | ||
| 17 | use crate::auth::extractor::AuthUser; | 17 | use crate::auth::extractor::AuthUser; |
| 18 | use crate::charts::{fmt_minutes, team_color}; | 18 | use crate::charts::{fmt_minutes, team_color}; |
| 19 | -use crate::db::{invitations, teams as teams_db, timers, todos as todos_db, users}; | 19 | +use crate::db::{invitations, teams as teams_db, timers, todos as todos_db}; |
| 20 | use crate::error::AppError; | 20 | use crate::error::AppError; |
| 21 | use crate::render::{Shell, TeamChoiceView, TimerView}; | 21 | use crate::render::{Shell, TeamChoiceView, TimerView}; |
| 22 | use crate::state::AppState; | 22 | use crate::state::AppState; |
| @@ -47,6 +47,7 @@ pub fn router(state: AppState) -> Router { | |||
| 47 | "/parametres/mot-de-passe", | 47 | "/parametres/mot-de-passe", |
| 48 | post(settings::password_change), | 48 | post(settings::password_change), |
| 49 | ) | 49 | ) |
| 50 | + .route("/parametres/langue", post(settings::lang_change)) | ||
| 50 | .route("/taches", get(todos::todos_page).post(todos::todo_create)) | 51 | .route("/taches", get(todos::todos_page).post(todos::todo_create)) |
| 51 | .route("/taches/nouvelle", get(todos::todo_new)) | 52 | .route("/taches/nouvelle", get(todos::todo_new)) |
| 52 | .route("/taches/{id}/modifier", get(todos::todo_edit)) | 53 | .route("/taches/{id}/modifier", get(todos::todo_edit)) |
| @@ -130,12 +131,10 @@ pub async fn shell( | |||
| 130 | tab: &'static str, | 131 | tab: &'static str, |
| 131 | toast: Option<String>, | 132 | toast: Option<String>, |
| 132 | ) -> Result<Shell, AppError> { | 133 | ) -> Result<Shell, AppError> { |
| 133 | - let username = users::username(&state.pool, ctx.user.id) | ||
| 134 | - .await? | ||
| 135 | - .unwrap_or_default(); | ||
| 136 | let invite_count = invitations::count_for(&state.pool, ctx.user.id).await?; | 134 | let invite_count = invitations::count_for(&state.pool, ctx.user.id).await?; |
| 137 | let todo_count = | 135 | let todo_count = |
| 138 | todos_db::open_count(&state.pool, ctx.team_id, crate::period::paris_today()).await?; | 136 | todos_db::open_count(&state.pool, ctx.team_id, crate::period::paris_today()).await?; |
| 137 | + let tr = ctx.tr(); | ||
| 139 | let timer_row = timers::get(&state.pool, ctx.user.id).await?; | 138 | let timer_row = timers::get(&state.pool, ctx.user.id).await?; |
| 140 | let badge = timer_row.is_some(); | 139 | let badge = timer_row.is_some(); |
| 141 | let timer = timer_row.map(|t| { | 140 | let timer = timer_row.map(|t| { |
| @@ -144,9 +143,9 @@ pub async fn shell( | |||
| 144 | Some(m) => TimerView { | 143 | Some(m) => TimerView { |
| 145 | running: false, | 144 | running: false, |
| 146 | foreign, | 145 | foreign, |
| 147 | - label: format!("{} mesurées", fmt_minutes(m)), | 146 | + label: crate::i18n::p1(tr.measured_pat, fmt_minutes(m)), |
| 148 | note: if foreign { | 147 | note: if foreign { |
| 149 | - format!("dans l’équipe {}", t.team_name) | 148 | + crate::i18n::p1(tr.in_team_pat, &t.team_name) |
| 150 | } else { | 149 | } else { |
| 151 | String::new() | 150 | String::new() |
| 152 | }, | 151 | }, |
| @@ -157,9 +156,9 @@ pub async fn shell( | |||
| 157 | foreign, | 156 | foreign, |
| 158 | label: fmt_minutes(t.elapsed_minutes(chrono::Utc::now())), | 157 | label: fmt_minutes(t.elapsed_minutes(chrono::Utc::now())), |
| 159 | note: if foreign { | 158 | note: if foreign { |
| 160 | - format!("timer dans l’équipe {}", t.team_name) | 159 | + crate::i18n::p1(tr.timer_in_team_pat, &t.team_name) |
| 161 | } else { | 160 | } else { |
| 162 | - "en cours".to_string() | 161 | + tr.timer_running_note.to_string() |
| 163 | }, | 162 | }, |
| 164 | started_ms: t.started_at.timestamp_millis(), | 163 | started_ms: t.started_at.timestamp_millis(), |
| 165 | }, | 164 | }, |
| @@ -167,7 +166,9 @@ pub async fn shell( | |||
| 167 | }); | 166 | }); |
| 168 | Ok(Shell { | 167 | Ok(Shell { |
| 169 | title: title.to_string(), | 168 | title: title.to_string(), |
| 170 | - username, | 169 | + username: ctx.username.clone(), |
| 170 | + lang_code: ctx.lang.code(), | ||
| 171 | + tr, | ||
| 171 | team_name: ctx.team_name.clone(), | 172 | team_name: ctx.team_name.clone(), |
| 172 | team_color: team_color(ctx.team_id), | 173 | team_color: team_color(ctx.team_id), |
| 173 | tab, | 174 | tab, |
| @@ -191,7 +192,7 @@ pub async fn shell( | |||
| 191 | mine.into_iter() | 192 | mine.into_iter() |
| 192 | .map(|t| TeamChoiceView { | 193 | .map(|t| TeamChoiceView { |
| 193 | current: t.id == ctx.team_id, | 194 | current: t.id == ctx.team_id, |
| 194 | - meta: format!("{} membre{}", t.members, if t.members > 1 { "s" } else { "" }), | 195 | + meta: tr.members_count(t.members), |
| 195 | color: team_color(t.id), | 196 | color: team_color(t.id), |
| 196 | id: t.id, | 197 | id: t.id, |
| 197 | name: t.name, | 198 | name: t.name, |
modified
src/routes/settings.rs +81 -37 | @@ -9,6 +9,7 @@ use crate::auth::password; | ||
| 9 | 9 | use crate::charts::color_map; |
| 10 | 10 | use crate::db::{invitations, categories, teams, users}; |
| 11 | 11 | use crate::error::AppError; |
| 12 | +use crate::i18n::{p1, p2}; | |
| 12 | 13 | use crate::render::{HtmlTemplate, Shell}; |
| 13 | 14 | use crate::routes::shell; |
| 14 | 15 | use crate::state::AppState; |
| @@ -40,6 +41,10 @@ pub struct SettingsPage { | ||
| 40 | 41 | pub members: Vec<String>, |
| 41 | 42 | /// Erreur inline du bloc invitation. |
| 42 | 43 | pub invite_error: Option<String>, |
| 44 | + /// Sélecteur de langue (par utilisateur, en base). | |
| 45 | + pub langs: Vec<LangView>, | |
| 46 | + pub members_title: String, | |
| 47 | + pub leave_label: String, | |
| 43 | 48 | pub sent: Vec<SentView>, |
| 44 | 49 | pub can_leave: bool, |
| 45 | 50 | } |
| @@ -57,33 +62,35 @@ pub async fn settings_page( | ||
| 57 | 62 | ctx: ActiveTeam, |
| 58 | 63 | Query(q): Query<SettingsQuery>, |
| 59 | 64 | ) -> Result<HtmlTemplate<SettingsPage>, AppError> { |
| 65 | + let tr = ctx.tr(); | |
| 60 | 66 | let toast = match q.ok.as_deref() { |
| 61 | - Some("mdp") => Some("Mot de passe mis à jour".to_string()), | |
| 62 | - Some("invite") => Some("Invitation envoyée".to_string()), | |
| 63 | - Some("rejoint") => Some(format!("Tu as rejoint {}", ctx.team_name)), | |
| 64 | - Some("refus") => Some("Invitation refusée".to_string()), | |
| 65 | - Some("annulee") => Some("Invitation annulée".to_string()), | |
| 66 | - Some("equipe-creee") => Some(format!("Équipe {} créée", ctx.team_name)), | |
| 67 | - Some("eq-renommee") => Some("Équipe renommée".to_string()), | |
| 68 | - Some("quittee") => Some("Équipe quittée".to_string()), | |
| 67 | + Some("mdp") => Some(tr.toast_password_updated.to_string()), | |
| 68 | + Some("invite") => Some(tr.toast_invite_sent.to_string()), | |
| 69 | + Some("rejoint") => Some(p1(tr.toast_joined_pat, &ctx.team_name)), | |
| 70 | + Some("refus") => Some(tr.toast_invite_refused.to_string()), | |
| 71 | + Some("annulee") => Some(tr.toast_invite_cancelled.to_string()), | |
| 72 | + Some("equipe-creee") => Some(p1(tr.team_created_pat, &ctx.team_name)), | |
| 73 | + Some("eq-renommee") => Some(tr.toast_team_renamed.to_string()), | |
| 74 | + Some("quittee") => Some(tr.toast_team_left.to_string()), | |
| 75 | + Some("langue") => Some(tr.toast_lang_updated.to_string()), | |
| 69 | 76 | _ => match q.err.as_deref() { |
| 70 | - Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()), | |
| 71 | - Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()), | |
| 72 | - Some("derniere") => Some("Impossible de quitter ta dernière équipe".to_string()), | |
| 77 | + Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()), | |
| 78 | + Some("eq-vide") => Some(tr.err_team_name_empty.to_string()), | |
| 79 | + Some("derniere") => Some(tr.err_last_team.to_string()), | |
| 73 | 80 | _ => None, |
| 74 | 81 | }, |
| 75 | 82 | }; |
| 76 | 83 | let error = match q.err.as_deref() { |
| 77 | - Some("mdp") => Some("L'ancien mot de passe est incorrect."), | |
| 78 | - Some("confirm") => Some("La confirmation ne correspond pas."), | |
| 79 | - Some("court") => Some("Le nouveau mot de passe doit faire au moins 8 caractères."), | |
| 84 | + Some("mdp") => Some(tr.err_password_wrong), | |
| 85 | + Some("confirm") => Some(tr.err_password_mismatch), | |
| 86 | + Some("court") => Some(tr.err_password_short), | |
| 80 | 87 | _ => None, |
| 81 | 88 | }; |
| 82 | 89 | let nom = q.nom.as_deref().unwrap_or(""); |
| 83 | 90 | let invite_error = match q.err.as_deref() { |
| 84 | - Some("inconnu") => Some(format!("Aucun compte « {nom} ».")), | |
| 85 | - Some("deja-membre") => Some(format!("{nom} fait déjà partie de l'équipe.")), | |
| 86 | - Some("deja-invite") => Some(format!("Une invitation est déjà en attente pour {nom}.")), | |
| 91 | + Some("inconnu") => Some(p1(tr.invite_unknown_pat, nom)), | |
| 92 | + Some("deja-membre") => Some(p1(tr.already_member_pat, nom)), | |
| 93 | + Some("deja-invite") => Some(p1(tr.already_invited_pat, nom)), | |
| 87 | 94 | _ => None, |
| 88 | 95 | }; |
| 89 | 96 | |
| @@ -91,7 +98,7 @@ pub async fn settings_page( | ||
| 91 | 98 | .await? |
| 92 | 99 | .into_iter() |
| 93 | 100 | .map(|i| InviteView { |
| 94 | - text: format!("{} t’invite à rejoindre l’équipe « {} ».", i.inviter, i.team_name), | |
| 101 | + text: p2(tr.invite_text_pat, &i.inviter, &i.team_name), | |
| 95 | 102 | accept_action: format!("/invitations/{}/accepter", i.id), |
| 96 | 103 | refuse_action: format!("/invitations/{}/refuser", i.id), |
| 97 | 104 | }) |
| @@ -99,11 +106,7 @@ pub async fn settings_page( | ||
| 99 | 106 | |
| 100 | 107 | let team_count = teams::teams_of(&state.pool, ctx.user.id).await?.len(); |
| 101 | 108 | let can_leave = team_count > 1; |
| 102 | - let account_line = if team_count > 1 { | |
| 103 | - format!("Membre de {team_count} équipes. Les comptes sont créés en ligne de commande.") | |
| 104 | - } else { | |
| 105 | - "Les comptes sont créés en ligne de commande.".to_string() | |
| 106 | - }; | |
| 109 | + let account_line = tr.account_line(team_count as i64); | |
| 107 | 110 | |
| 108 | 111 | let members = teams::members(&state.pool, ctx.team_id) |
| 109 | 112 | .await? |
| @@ -116,15 +119,26 @@ pub async fn settings_page( | ||
| 116 | 119 | .into_iter() |
| 117 | 120 | .map(|i| SentView { |
| 118 | 121 | name: i.invitee, |
| 119 | - meta: format!("invité par {} · en attente", i.inviter), | |
| 122 | + meta: p1(tr.invited_by_pat, &i.inviter), | |
| 120 | 123 | cancel_action: format!("/invitations/{}/annuler", i.id), |
| 121 | 124 | }) |
| 122 | 125 | .collect(); |
| 123 | 126 | |
| 127 | + let langs = crate::i18n::Lang::ALL | |
| 128 | + .into_iter() | |
| 129 | + .map(|l| LangView { | |
| 130 | + code: l.code(), | |
| 131 | + name: l.self_name(), | |
| 132 | + current: l == ctx.lang, | |
| 133 | + }) | |
| 134 | + .collect(); | |
| 124 | 135 | Ok(HtmlTemplate(SettingsPage { |
| 125 | 136 | csrf: ctx.user.csrf_token(&state), |
| 126 | - shell: shell(&state, &ctx, "Paramètres", "settings", toast).await?, | |
| 137 | + shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?, | |
| 127 | 138 | error, |
| 139 | + langs, | |
| 140 | + members_title: p1(tr.members_of_pat, &ctx.team_name), | |
| 141 | + leave_label: p1(tr.leave_team_pat, &ctx.team_name), | |
| 128 | 142 | invites, |
| 129 | 143 | account_line, |
| 130 | 144 | members, |
| @@ -149,6 +163,7 @@ pub struct CategoryView { | ||
| 149 | 163 | pub struct CategoriesPage { |
| 150 | 164 | pub shell: Shell, |
| 151 | 165 | pub csrf: String, |
| 166 | + pub hint: String, | |
| 152 | 167 | pub categories: Vec<CategoryView>, |
| 153 | 168 | } |
| 154 | 169 | |
| @@ -157,15 +172,14 @@ pub async fn categories_page( | ||
| 157 | 172 | ctx: ActiveTeam, |
| 158 | 173 | Query(q): Query<SettingsQuery>, |
| 159 | 174 | ) -> Result<HtmlTemplate<CategoriesPage>, AppError> { |
| 175 | + let tr = ctx.tr(); | |
| 160 | 176 | let toast = match (q.ok.as_deref(), q.err.as_deref()) { |
| 161 | - (Some("ajoutee"), _) => Some("Catégorie ajoutée".to_string()), | |
| 162 | - (Some("renommee"), _) => Some("Catégorie renommée".to_string()), | |
| 163 | - (Some("archivee"), _) => { | |
| 164 | - Some("Catégorie archivée — les temps restent dans les stats".to_string()) | |
| 165 | - } | |
| 166 | - (Some("restauree"), _) => Some("Catégorie restaurée".to_string()), | |
| 167 | - (_, Some("doublon")) => Some("Une catégorie porte déjà ce nom".to_string()), | |
| 168 | - (_, Some("vide")) => Some("Le nom ne peut pas être vide".to_string()), | |
| 177 | + (Some("ajoutee"), _) => Some(tr.toast_cat_added.to_string()), | |
| 178 | + (Some("renommee"), _) => Some(tr.toast_cat_renamed.to_string()), | |
| 179 | + (Some("archivee"), _) => Some(tr.toast_cat_archived.to_string()), | |
| 180 | + (Some("restauree"), _) => Some(tr.toast_cat_restored.to_string()), | |
| 181 | + (_, Some("doublon")) => Some(tr.err_cat_dup.to_string()), | |
| 182 | + (_, Some("vide")) => Some(tr.err_cat_empty.to_string()), | |
| 169 | 183 | _ => None, |
| 170 | 184 | }; |
| 171 | 185 | let by_creation = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| @@ -184,17 +198,47 @@ pub async fn categories_page( | ||
| 184 | 198 | .collect(); |
| 185 | 199 | Ok(HtmlTemplate(CategoriesPage { |
| 186 | 200 | csrf: ctx.user.csrf_token(&state), |
| 187 | - shell: shell(&state, &ctx, "Catégories", "add", toast).await?, | |
| 201 | + hint: p1(tr.categories_hint_pat, &ctx.team_name), | |
| 202 | + shell: shell(&state, &ctx, tr.title_categories, "add", toast).await?, | |
| 188 | 203 | categories, |
| 189 | 204 | })) |
| 190 | 205 | } |
| 191 | 206 | |
| 192 | 207 | #[derive(Deserialize)] |
| 193 | -pub struct CategoryFormb { | |
| 208 | +pub struct CategoryForm { | |
| 194 | 209 | name: String, |
| 195 | 210 | csrf: String, |
| 196 | 211 | } |
| 197 | 212 | |
| 213 | +pub struct LangView { | |
| 214 | + pub code: &'static str, | |
| 215 | + pub name: &'static str, | |
| 216 | + pub current: bool, | |
| 217 | +} | |
| 218 | + | |
| 219 | +#[derive(Deserialize)] | |
| 220 | +pub struct LangForm { | |
| 221 | + langue: String, | |
| 222 | + csrf: String, | |
| 223 | +} | |
| 224 | + | |
| 225 | +/// POST /parametres/langue — choix PAR UTILISATEUR, enregistré en base | |
| 226 | +/// (jamais par navigateur : le réglage suit le compte partout). | |
| 227 | +pub async fn lang_change( | |
| 228 | + State(state): State<AppState>, | |
| 229 | + ctx: ActiveTeam, | |
| 230 | + Form(form): Form<LangForm>, | |
| 231 | +) -> Result<Redirect, AppError> { | |
| 232 | + if !ctx.user.check_csrf(&state, &form.csrf) { | |
| 233 | + return Err(AppError::Csrf); | |
| 234 | + } | |
| 235 | + let Some(lang) = crate::i18n::Lang::from_code(&form.langue) else { | |
| 236 | + return Err(AppError::BadRequest("bad lang".into())); | |
| 237 | + }; | |
| 238 | + users::set_lang(&state.pool, ctx.user.id, lang.code()).await?; | |
| 239 | + Ok(Redirect::to("/parametres?ok=langue")) | |
| 240 | +} | |
| 241 | + | |
| 198 | 242 | fn is_unique_violation(e: &sqlx::Error) -> bool { |
| 199 | 243 | matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) |
| 200 | 244 | } |
| @@ -202,7 +246,7 @@ fn is_unique_violation(e: &sqlx::Error) -> bool { | ||
| 202 | 246 | pub async fn category_create( |
| 203 | 247 | State(state): State<AppState>, |
| 204 | 248 | ctx: ActiveTeam, |
| 205 | - Form(form): Form<CategoryFormb>, | |
| 249 | + Form(form): Form<CategoryForm>, | |
| 206 | 250 | ) -> Result<Redirect, AppError> { |
| 207 | 251 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 208 | 252 | return Err(AppError::Csrf); |
| @@ -222,7 +266,7 @@ pub async fn category_rename( | ||
| 222 | 266 | State(state): State<AppState>, |
| 223 | 267 | ctx: ActiveTeam, |
| 224 | 268 | Path(id): Path<Uuid>, |
| 225 | - Form(form): Form<CategoryFormb>, | |
| 269 | + Form(form): Form<CategoryForm>, | |
| 226 | 270 | ) -> Result<Redirect, AppError> { |
| 227 | 271 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 228 | 272 | return Err(AppError::Csrf); |
| @@ -9,6 +9,7 @@ use crate::auth::password; | |||
| 9 | use crate::charts::color_map; | 9 | use crate::charts::color_map; |
| 10 | use crate::db::{invitations, categories, teams, users}; | 10 | use crate::db::{invitations, categories, teams, users}; |
| 11 | use crate::error::AppError; | 11 | use crate::error::AppError; |
| 12 | +use crate::i18n::{p1, p2}; | ||
| 12 | use crate::render::{HtmlTemplate, Shell}; | 13 | use crate::render::{HtmlTemplate, Shell}; |
| 13 | use crate::routes::shell; | 14 | use crate::routes::shell; |
| 14 | use crate::state::AppState; | 15 | use crate::state::AppState; |
| @@ -40,6 +41,10 @@ pub struct SettingsPage { | |||
| 40 | pub members: Vec<String>, | 41 | pub members: Vec<String>, |
| 41 | /// Erreur inline du bloc invitation. | 42 | /// Erreur inline du bloc invitation. |
| 42 | pub invite_error: Option<String>, | 43 | pub invite_error: Option<String>, |
| 44 | + /// Sélecteur de langue (par utilisateur, en base). | ||
| 45 | + pub langs: Vec<LangView>, | ||
| 46 | + pub members_title: String, | ||
| 47 | + pub leave_label: String, | ||
| 43 | pub sent: Vec<SentView>, | 48 | pub sent: Vec<SentView>, |
| 44 | pub can_leave: bool, | 49 | pub can_leave: bool, |
| 45 | } | 50 | } |
| @@ -57,33 +62,35 @@ pub async fn settings_page( | |||
| 57 | ctx: ActiveTeam, | 62 | ctx: ActiveTeam, |
| 58 | Query(q): Query<SettingsQuery>, | 63 | Query(q): Query<SettingsQuery>, |
| 59 | ) -> Result<HtmlTemplate<SettingsPage>, AppError> { | 64 | ) -> Result<HtmlTemplate<SettingsPage>, AppError> { |
| 65 | + let tr = ctx.tr(); | ||
| 60 | let toast = match q.ok.as_deref() { | 66 | let toast = match q.ok.as_deref() { |
| 61 | - Some("mdp") => Some("Mot de passe mis à jour".to_string()), | 67 | + Some("mdp") => Some(tr.toast_password_updated.to_string()), |
| 62 | - Some("invite") => Some("Invitation envoyée".to_string()), | 68 | + Some("invite") => Some(tr.toast_invite_sent.to_string()), |
| 63 | - Some("rejoint") => Some(format!("Tu as rejoint {}", ctx.team_name)), | 69 | + Some("rejoint") => Some(p1(tr.toast_joined_pat, &ctx.team_name)), |
| 64 | - Some("refus") => Some("Invitation refusée".to_string()), | 70 | + Some("refus") => Some(tr.toast_invite_refused.to_string()), |
| 65 | - Some("annulee") => Some("Invitation annulée".to_string()), | 71 | + Some("annulee") => Some(tr.toast_invite_cancelled.to_string()), |
| 66 | - Some("equipe-creee") => Some(format!("Équipe {} créée", ctx.team_name)), | 72 | + Some("equipe-creee") => Some(p1(tr.team_created_pat, &ctx.team_name)), |
| 67 | - Some("eq-renommee") => Some("Équipe renommée".to_string()), | 73 | + Some("eq-renommee") => Some(tr.toast_team_renamed.to_string()), |
| 68 | - Some("quittee") => Some("Équipe quittée".to_string()), | 74 | + Some("quittee") => Some(tr.toast_team_left.to_string()), |
| 75 | + Some("langue") => Some(tr.toast_lang_updated.to_string()), | ||
| 69 | _ => match q.err.as_deref() { | 76 | _ => match q.err.as_deref() { |
| 70 | - Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()), | 77 | + Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()), |
| 71 | - Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()), | 78 | + Some("eq-vide") => Some(tr.err_team_name_empty.to_string()), |
| 72 | - Some("derniere") => Some("Impossible de quitter ta dernière équipe".to_string()), | 79 | + Some("derniere") => Some(tr.err_last_team.to_string()), |
| 73 | _ => None, | 80 | _ => None, |
| 74 | }, | 81 | }, |
| 75 | }; | 82 | }; |
| 76 | let error = match q.err.as_deref() { | 83 | let error = match q.err.as_deref() { |
| 77 | - Some("mdp") => Some("L'ancien mot de passe est incorrect."), | 84 | + Some("mdp") => Some(tr.err_password_wrong), |
| 78 | - Some("confirm") => Some("La confirmation ne correspond pas."), | 85 | + Some("confirm") => Some(tr.err_password_mismatch), |
| 79 | - Some("court") => Some("Le nouveau mot de passe doit faire au moins 8 caractères."), | 86 | + Some("court") => Some(tr.err_password_short), |
| 80 | _ => None, | 87 | _ => None, |
| 81 | }; | 88 | }; |
| 82 | let nom = q.nom.as_deref().unwrap_or(""); | 89 | let nom = q.nom.as_deref().unwrap_or(""); |
| 83 | let invite_error = match q.err.as_deref() { | 90 | let invite_error = match q.err.as_deref() { |
| 84 | - Some("inconnu") => Some(format!("Aucun compte « {nom} ».")), | 91 | + Some("inconnu") => Some(p1(tr.invite_unknown_pat, nom)), |
| 85 | - Some("deja-membre") => Some(format!("{nom} fait déjà partie de l'équipe.")), | 92 | + Some("deja-membre") => Some(p1(tr.already_member_pat, nom)), |
| 86 | - Some("deja-invite") => Some(format!("Une invitation est déjà en attente pour {nom}.")), | 93 | + Some("deja-invite") => Some(p1(tr.already_invited_pat, nom)), |
| 87 | _ => None, | 94 | _ => None, |
| 88 | }; | 95 | }; |
| 89 | 96 | ||
| @@ -91,7 +98,7 @@ pub async fn settings_page( | |||
| 91 | .await? | 98 | .await? |
| 92 | .into_iter() | 99 | .into_iter() |
| 93 | .map(|i| InviteView { | 100 | .map(|i| InviteView { |
| 94 | - text: format!("{} t’invite à rejoindre l’équipe « {} ».", i.inviter, i.team_name), | 101 | + text: p2(tr.invite_text_pat, &i.inviter, &i.team_name), |
| 95 | accept_action: format!("/invitations/{}/accepter", i.id), | 102 | accept_action: format!("/invitations/{}/accepter", i.id), |
| 96 | refuse_action: format!("/invitations/{}/refuser", i.id), | 103 | refuse_action: format!("/invitations/{}/refuser", i.id), |
| 97 | }) | 104 | }) |
| @@ -99,11 +106,7 @@ pub async fn settings_page( | |||
| 99 | 106 | ||
| 100 | let team_count = teams::teams_of(&state.pool, ctx.user.id).await?.len(); | 107 | let team_count = teams::teams_of(&state.pool, ctx.user.id).await?.len(); |
| 101 | let can_leave = team_count > 1; | 108 | let can_leave = team_count > 1; |
| 102 | - let account_line = if team_count > 1 { | 109 | + let account_line = tr.account_line(team_count as i64); |
| 103 | - format!("Membre de {team_count} équipes. Les comptes sont créés en ligne de commande.") | ||
| 104 | - } else { | ||
| 105 | - "Les comptes sont créés en ligne de commande.".to_string() | ||
| 106 | - }; | ||
| 107 | 110 | ||
| 108 | let members = teams::members(&state.pool, ctx.team_id) | 111 | let members = teams::members(&state.pool, ctx.team_id) |
| 109 | .await? | 112 | .await? |
| @@ -116,15 +119,26 @@ pub async fn settings_page( | |||
| 116 | .into_iter() | 119 | .into_iter() |
| 117 | .map(|i| SentView { | 120 | .map(|i| SentView { |
| 118 | name: i.invitee, | 121 | name: i.invitee, |
| 119 | - meta: format!("invité par {} · en attente", i.inviter), | 122 | + meta: p1(tr.invited_by_pat, &i.inviter), |
| 120 | cancel_action: format!("/invitations/{}/annuler", i.id), | 123 | cancel_action: format!("/invitations/{}/annuler", i.id), |
| 121 | }) | 124 | }) |
| 122 | .collect(); | 125 | .collect(); |
| 123 | 126 | ||
| 127 | + let langs = crate::i18n::Lang::ALL | ||
| 128 | + .into_iter() | ||
| 129 | + .map(|l| LangView { | ||
| 130 | + code: l.code(), | ||
| 131 | + name: l.self_name(), | ||
| 132 | + current: l == ctx.lang, | ||
| 133 | + }) | ||
| 134 | + .collect(); | ||
| 124 | Ok(HtmlTemplate(SettingsPage { | 135 | Ok(HtmlTemplate(SettingsPage { |
| 125 | csrf: ctx.user.csrf_token(&state), | 136 | csrf: ctx.user.csrf_token(&state), |
| 126 | - shell: shell(&state, &ctx, "Paramètres", "settings", toast).await?, | 137 | + shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?, |
| 127 | error, | 138 | error, |
| 139 | + langs, | ||
| 140 | + members_title: p1(tr.members_of_pat, &ctx.team_name), | ||
| 141 | + leave_label: p1(tr.leave_team_pat, &ctx.team_name), | ||
| 128 | invites, | 142 | invites, |
| 129 | account_line, | 143 | account_line, |
| 130 | members, | 144 | members, |
| @@ -149,6 +163,7 @@ pub struct CategoryView { | |||
| 149 | pub struct CategoriesPage { | 163 | pub struct CategoriesPage { |
| 150 | pub shell: Shell, | 164 | pub shell: Shell, |
| 151 | pub csrf: String, | 165 | pub csrf: String, |
| 166 | + pub hint: String, | ||
| 152 | pub categories: Vec<CategoryView>, | 167 | pub categories: Vec<CategoryView>, |
| 153 | } | 168 | } |
| 154 | 169 | ||
| @@ -157,15 +172,14 @@ pub async fn categories_page( | |||
| 157 | ctx: ActiveTeam, | 172 | ctx: ActiveTeam, |
| 158 | Query(q): Query<SettingsQuery>, | 173 | Query(q): Query<SettingsQuery>, |
| 159 | ) -> Result<HtmlTemplate<CategoriesPage>, AppError> { | 174 | ) -> Result<HtmlTemplate<CategoriesPage>, AppError> { |
| 175 | + let tr = ctx.tr(); | ||
| 160 | let toast = match (q.ok.as_deref(), q.err.as_deref()) { | 176 | let toast = match (q.ok.as_deref(), q.err.as_deref()) { |
| 161 | - (Some("ajoutee"), _) => Some("Catégorie ajoutée".to_string()), | 177 | + (Some("ajoutee"), _) => Some(tr.toast_cat_added.to_string()), |
| 162 | - (Some("renommee"), _) => Some("Catégorie renommée".to_string()), | 178 | + (Some("renommee"), _) => Some(tr.toast_cat_renamed.to_string()), |
| 163 | - (Some("archivee"), _) => { | 179 | + (Some("archivee"), _) => Some(tr.toast_cat_archived.to_string()), |
| 164 | - Some("Catégorie archivée — les temps restent dans les stats".to_string()) | 180 | + (Some("restauree"), _) => Some(tr.toast_cat_restored.to_string()), |
| 165 | - } | 181 | + (_, Some("doublon")) => Some(tr.err_cat_dup.to_string()), |
| 166 | - (Some("restauree"), _) => Some("Catégorie restaurée".to_string()), | 182 | + (_, Some("vide")) => Some(tr.err_cat_empty.to_string()), |
| 167 | - (_, Some("doublon")) => Some("Une catégorie porte déjà ce nom".to_string()), | ||
| 168 | - (_, Some("vide")) => Some("Le nom ne peut pas être vide".to_string()), | ||
| 169 | _ => None, | 183 | _ => None, |
| 170 | }; | 184 | }; |
| 171 | let by_creation = categories::list_by_creation(&state.pool, ctx.team_id).await?; | 185 | let by_creation = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| @@ -184,17 +198,47 @@ pub async fn categories_page( | |||
| 184 | .collect(); | 198 | .collect(); |
| 185 | Ok(HtmlTemplate(CategoriesPage { | 199 | Ok(HtmlTemplate(CategoriesPage { |
| 186 | csrf: ctx.user.csrf_token(&state), | 200 | csrf: ctx.user.csrf_token(&state), |
| 187 | - shell: shell(&state, &ctx, "Catégories", "add", toast).await?, | 201 | + hint: p1(tr.categories_hint_pat, &ctx.team_name), |
| 202 | + shell: shell(&state, &ctx, tr.title_categories, "add", toast).await?, | ||
| 188 | categories, | 203 | categories, |
| 189 | })) | 204 | })) |
| 190 | } | 205 | } |
| 191 | 206 | ||
| 192 | #[derive(Deserialize)] | 207 | #[derive(Deserialize)] |
| 193 | -pub struct CategoryFormb { | 208 | +pub struct CategoryForm { |
| 194 | name: String, | 209 | name: String, |
| 195 | csrf: String, | 210 | csrf: String, |
| 196 | } | 211 | } |
| 197 | 212 | ||
| 213 | +pub struct LangView { | ||
| 214 | + pub code: &'static str, | ||
| 215 | + pub name: &'static str, | ||
| 216 | + pub current: bool, | ||
| 217 | +} | ||
| 218 | + | ||
| 219 | +#[derive(Deserialize)] | ||
| 220 | +pub struct LangForm { | ||
| 221 | + langue: String, | ||
| 222 | + csrf: String, | ||
| 223 | +} | ||
| 224 | + | ||
| 225 | +/// POST /parametres/langue — choix PAR UTILISATEUR, enregistré en base | ||
| 226 | +/// (jamais par navigateur : le réglage suit le compte partout). | ||
| 227 | +pub async fn lang_change( | ||
| 228 | + State(state): State<AppState>, | ||
| 229 | + ctx: ActiveTeam, | ||
| 230 | + Form(form): Form<LangForm>, | ||
| 231 | +) -> Result<Redirect, AppError> { | ||
| 232 | + if !ctx.user.check_csrf(&state, &form.csrf) { | ||
| 233 | + return Err(AppError::Csrf); | ||
| 234 | + } | ||
| 235 | + let Some(lang) = crate::i18n::Lang::from_code(&form.langue) else { | ||
| 236 | + return Err(AppError::BadRequest("bad lang".into())); | ||
| 237 | + }; | ||
| 238 | + users::set_lang(&state.pool, ctx.user.id, lang.code()).await?; | ||
| 239 | + Ok(Redirect::to("/parametres?ok=langue")) | ||
| 240 | +} | ||
| 241 | + | ||
| 198 | fn is_unique_violation(e: &sqlx::Error) -> bool { | 242 | fn is_unique_violation(e: &sqlx::Error) -> bool { |
| 199 | matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) | 243 | matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) |
| 200 | } | 244 | } |
| @@ -202,7 +246,7 @@ fn is_unique_violation(e: &sqlx::Error) -> bool { | |||
| 202 | pub async fn category_create( | 246 | pub async fn category_create( |
| 203 | State(state): State<AppState>, | 247 | State(state): State<AppState>, |
| 204 | ctx: ActiveTeam, | 248 | ctx: ActiveTeam, |
| 205 | - Form(form): Form<CategoryFormb>, | 249 | + Form(form): Form<CategoryForm>, |
| 206 | ) -> Result<Redirect, AppError> { | 250 | ) -> Result<Redirect, AppError> { |
| 207 | if !ctx.user.check_csrf(&state, &form.csrf) { | 251 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 208 | return Err(AppError::Csrf); | 252 | return Err(AppError::Csrf); |
| @@ -222,7 +266,7 @@ pub async fn category_rename( | |||
| 222 | State(state): State<AppState>, | 266 | State(state): State<AppState>, |
| 223 | ctx: ActiveTeam, | 267 | ctx: ActiveTeam, |
| 224 | Path(id): Path<Uuid>, | 268 | Path(id): Path<Uuid>, |
| 225 | - Form(form): Form<CategoryFormb>, | 269 | + Form(form): Form<CategoryForm>, |
| 226 | ) -> Result<Redirect, AppError> { | 270 | ) -> Result<Redirect, AppError> { |
| 227 | if !ctx.user.check_csrf(&state, &form.csrf) { | 271 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 228 | return Err(AppError::Csrf); | 272 | return Err(AppError::Csrf); |
modified
src/routes/stats.rs +14 -9 | @@ -89,10 +89,11 @@ pub async fn stats_page( | ||
| 89 | 89 | |
| 90 | 90 | let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors); |
| 91 | 91 | |
| 92 | + let tr = ctx.tr(); | |
| 92 | 93 | let (week_chart, mut sheets) = build_chart( |
| 93 | 94 | "sem", |
| 94 | - "Cette semaine", | |
| 95 | - &week.range_label(), | |
| 95 | + tr.this_week, | |
| 96 | + &week.range_label(ctx.lang), | |
| 96 | 97 | &all_users, |
| 97 | 98 | &week_rows, |
| 98 | 99 | &colors, |
| @@ -100,11 +101,12 @@ pub async fn stats_page( | ||
| 100 | 101 | prev: href(week.prev(), month, year), |
| 101 | 102 | next: (week != Week::current()).then(|| href(week.next(), month, year)), |
| 102 | 103 | }), |
| 104 | + tr, | |
| 103 | 105 | ); |
| 104 | 106 | let (month_chart, s2) = build_chart( |
| 105 | 107 | "mois", |
| 106 | - "Ce mois-ci", | |
| 107 | - &month.label(), | |
| 108 | + tr.this_month, | |
| 109 | + &month.label(ctx.lang), | |
| 108 | 110 | &all_users, |
| 109 | 111 | &month_rows, |
| 110 | 112 | &colors, |
| @@ -112,11 +114,12 @@ pub async fn stats_page( | ||
| 112 | 114 | prev: href(week, month.prev(), year), |
| 113 | 115 | next: (month != Month::current()).then(|| href(week, month.next(), year)), |
| 114 | 116 | }), |
| 117 | + tr, | |
| 115 | 118 | ); |
| 116 | 119 | let (year_chart, s3) = build_chart( |
| 117 | 120 | "an", |
| 118 | - "Cette année", | |
| 119 | - &year.label(), | |
| 121 | + tr.this_year, | |
| 122 | + &year.label(ctx.lang), | |
| 120 | 123 | &all_users, |
| 121 | 124 | &year_rows, |
| 122 | 125 | &colors, |
| @@ -124,22 +127,24 @@ pub async fn stats_page( | ||
| 124 | 127 | prev: href(week, month, year.prev()), |
| 125 | 128 | next: (year != Year::current()).then(|| href(week, month, year.next())), |
| 126 | 129 | }), |
| 130 | + tr, | |
| 127 | 131 | ); |
| 128 | 132 | let (total_chart, s4) = build_chart( |
| 129 | 133 | "tot", |
| 130 | - "Depuis le début", | |
| 131 | - "Tout l'historique", | |
| 134 | + tr.all_time, | |
| 135 | + tr.all_history, | |
| 132 | 136 | &all_users, |
| 133 | 137 | &total_rows, |
| 134 | 138 | &colors, |
| 135 | 139 | None, |
| 140 | + tr, | |
| 136 | 141 | ); |
| 137 | 142 | sheets.extend(s2); |
| 138 | 143 | sheets.extend(s3); |
| 139 | 144 | sheets.extend(s4); |
| 140 | 145 | |
| 141 | 146 | Ok(HtmlTemplate(StatsPage { |
| 142 | - shell: shell(&state, &ctx, "Statistiques", "stats", None).await?, | |
| 147 | + shell: shell(&state, &ctx, tr.title_stats, "stats", None).await?, | |
| 143 | 148 | legend, |
| 144 | 149 | charts: vec![week_chart, month_chart, year_chart, total_chart], |
| 145 | 150 | sheets, |
| @@ -89,10 +89,11 @@ pub async fn stats_page( | |||
| 89 | 89 | ||
| 90 | let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors); | 90 | let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors); |
| 91 | 91 | ||
| 92 | + let tr = ctx.tr(); | ||
| 92 | let (week_chart, mut sheets) = build_chart( | 93 | let (week_chart, mut sheets) = build_chart( |
| 93 | "sem", | 94 | "sem", |
| 94 | - "Cette semaine", | 95 | + tr.this_week, |
| 95 | - &week.range_label(), | 96 | + &week.range_label(ctx.lang), |
| 96 | &all_users, | 97 | &all_users, |
| 97 | &week_rows, | 98 | &week_rows, |
| 98 | &colors, | 99 | &colors, |
| @@ -100,11 +101,12 @@ pub async fn stats_page( | |||
| 100 | prev: href(week.prev(), month, year), | 101 | prev: href(week.prev(), month, year), |
| 101 | next: (week != Week::current()).then(|| href(week.next(), month, year)), | 102 | next: (week != Week::current()).then(|| href(week.next(), month, year)), |
| 102 | }), | 103 | }), |
| 104 | + tr, | ||
| 103 | ); | 105 | ); |
| 104 | let (month_chart, s2) = build_chart( | 106 | let (month_chart, s2) = build_chart( |
| 105 | "mois", | 107 | "mois", |
| 106 | - "Ce mois-ci", | 108 | + tr.this_month, |
| 107 | - &month.label(), | 109 | + &month.label(ctx.lang), |
| 108 | &all_users, | 110 | &all_users, |
| 109 | &month_rows, | 111 | &month_rows, |
| 110 | &colors, | 112 | &colors, |
| @@ -112,11 +114,12 @@ pub async fn stats_page( | |||
| 112 | prev: href(week, month.prev(), year), | 114 | prev: href(week, month.prev(), year), |
| 113 | next: (month != Month::current()).then(|| href(week, month.next(), year)), | 115 | next: (month != Month::current()).then(|| href(week, month.next(), year)), |
| 114 | }), | 116 | }), |
| 117 | + tr, | ||
| 115 | ); | 118 | ); |
| 116 | let (year_chart, s3) = build_chart( | 119 | let (year_chart, s3) = build_chart( |
| 117 | "an", | 120 | "an", |
| 118 | - "Cette année", | 121 | + tr.this_year, |
| 119 | - &year.label(), | 122 | + &year.label(ctx.lang), |
| 120 | &all_users, | 123 | &all_users, |
| 121 | &year_rows, | 124 | &year_rows, |
| 122 | &colors, | 125 | &colors, |
| @@ -124,22 +127,24 @@ pub async fn stats_page( | |||
| 124 | prev: href(week, month, year.prev()), | 127 | prev: href(week, month, year.prev()), |
| 125 | next: (year != Year::current()).then(|| href(week, month, year.next())), | 128 | next: (year != Year::current()).then(|| href(week, month, year.next())), |
| 126 | }), | 129 | }), |
| 130 | + tr, | ||
| 127 | ); | 131 | ); |
| 128 | let (total_chart, s4) = build_chart( | 132 | let (total_chart, s4) = build_chart( |
| 129 | "tot", | 133 | "tot", |
| 130 | - "Depuis le début", | 134 | + tr.all_time, |
| 131 | - "Tout l'historique", | 135 | + tr.all_history, |
| 132 | &all_users, | 136 | &all_users, |
| 133 | &total_rows, | 137 | &total_rows, |
| 134 | &colors, | 138 | &colors, |
| 135 | None, | 139 | None, |
| 140 | + tr, | ||
| 136 | ); | 141 | ); |
| 137 | sheets.extend(s2); | 142 | sheets.extend(s2); |
| 138 | sheets.extend(s3); | 143 | sheets.extend(s3); |
| 139 | sheets.extend(s4); | 144 | sheets.extend(s4); |
| 140 | 145 | ||
| 141 | Ok(HtmlTemplate(StatsPage { | 146 | Ok(HtmlTemplate(StatsPage { |
| 142 | - shell: shell(&state, &ctx, "Statistiques", "stats", None).await?, | 147 | + shell: shell(&state, &ctx, tr.title_stats, "stats", None).await?, |
| 143 | legend, | 148 | legend, |
| 144 | charts: vec![week_chart, month_chart, year_chart, total_chart], | 149 | charts: vec![week_chart, month_chart, year_chart, total_chart], |
| 145 | sheets, | 150 | sheets, |
modified
src/routes/todos.rs +41 -25 | @@ -14,6 +14,7 @@ use crate::db::categories; | ||
| 14 | 14 | use crate::db::teams; |
| 15 | 15 | use crate::db::todos::{self, STATE_IN_PROGRESS, STATE_TODO, TodoRow}; |
| 16 | 16 | use crate::error::AppError; |
| 17 | +use crate::i18n::{Tr, p1, p2}; | |
| 17 | 18 | use crate::period::{day_label, day_month, paris_today}; |
| 18 | 19 | use crate::render::{HtmlTemplate, Shell}; |
| 19 | 20 | use crate::routes::shell; |
| @@ -33,6 +34,8 @@ pub struct AsgChip { | ||
| 33 | 34 | pub struct TodoCard { |
| 34 | 35 | pub id: Uuid, |
| 35 | 36 | pub name: String, |
| 37 | + /// « Modifier la tâche X » — le verbe se place différemment selon la langue. | |
| 38 | + pub edit_aria: String, | |
| 36 | 39 | pub color: &'static str, |
| 37 | 40 | /// « pris par julien · 5 août », « depuis le 3 août », « à partir du… » |
| 38 | 41 | pub meta: String, |
| @@ -53,6 +56,7 @@ pub struct TodoCard { | ||
| 53 | 56 | pub struct TodosPage { |
| 54 | 57 | pub shell: Shell, |
| 55 | 58 | pub csrf: String, |
| 59 | + pub empty_body: String, | |
| 56 | 60 | pub doing: Vec<TodoCard>, |
| 57 | 61 | pub open: Vec<TodoCard>, |
| 58 | 62 | pub later: Vec<TodoCard>, |
| @@ -74,16 +78,16 @@ pub struct ListQuery { | ||
| 74 | 78 | ok: Option<String>, |
| 75 | 79 | } |
| 76 | 80 | |
| 77 | -fn toast_of(ok: Option<&str>) -> Option<String> { | |
| 81 | +fn toast_of(tr: &Tr, ok: Option<&str>) -> Option<String> { | |
| 78 | 82 | Some( |
| 79 | 83 | match ok? { |
| 80 | - "creee" => "Tâche créée ✓", | |
| 81 | - "modifiee" => "Tâche modifiée ✓", | |
| 82 | - "supprimee" => "Tâche supprimée", | |
| 83 | - "prise" => "Tâche prise ✓", | |
| 84 | - "relachee" => "Tâche relâchée", | |
| 85 | - "faite" => "Tâche faite ✓", | |
| 86 | - "faite_reprog" => "Faite et reprogrammée ✓", | |
| 84 | + "creee" => tr.toast_todo_created, | |
| 85 | + "modifiee" => tr.toast_todo_edited, | |
| 86 | + "supprimee" => tr.toast_todo_deleted, | |
| 87 | + "prise" => tr.toast_todo_taken, | |
| 88 | + "relachee" => tr.toast_todo_released, | |
| 89 | + "faite" => tr.toast_todo_done, | |
| 90 | + "faite_reprog" => tr.toast_todo_done_reprog, | |
| 87 | 91 | _ => return None, |
| 88 | 92 | } |
| 89 | 93 | .to_string(), |
| @@ -105,6 +109,7 @@ pub async fn todos_page( | ||
| 105 | 109 | Query(q): Query<ListQuery>, |
| 106 | 110 | ) -> Result<HtmlTemplate<TodosPage>, AppError> { |
| 107 | 111 | let today = paris_today(); |
| 112 | + let tr = ctx.tr(); | |
| 108 | 113 | let me = ctx.user.id; |
| 109 | 114 | let doing_on = q.encours.as_deref() == Some("moi"); |
| 110 | 115 | let open_on = q.afaire.as_deref() == Some("moi"); |
| @@ -125,18 +130,19 @@ pub async fn todos_page( | ||
| 125 | 130 | let meta = if t.state_id == STATE_IN_PROGRESS { |
| 126 | 131 | let on = t |
| 127 | 132 | .taken_at |
| 128 | - .map(|at| day_month(at.with_timezone(&Paris).date_naive())) | |
| 133 | + .map(|at| day_month(ctx.lang, at.with_timezone(&Paris).date_naive())) | |
| 129 | 134 | .unwrap_or_default(); |
| 130 | - format!("pris par {} · {on}", t.taker_name.as_deref().unwrap_or("?")) | |
| 135 | + p2(tr.taken_by_pat, t.taker_name.as_deref().unwrap_or("?"), on) | |
| 131 | 136 | } else if late { |
| 132 | - format!("depuis le {}", day_month(t.activation_date)) | |
| 137 | + p1(tr.since_pat, day_month(ctx.lang, t.activation_date)) | |
| 133 | 138 | } else if t.activation_date == today { |
| 134 | - "activée aujourd'hui".to_string() | |
| 139 | + tr.activated_today.to_string() | |
| 135 | 140 | } else { |
| 136 | - format!("à partir du {}", day_month(t.activation_date)) | |
| 141 | + p1(tr.from_pat, day_month(ctx.lang, t.activation_date)) | |
| 137 | 142 | }; |
| 138 | 143 | TodoCard { |
| 139 | 144 | id: t.id, |
| 145 | + edit_aria: p1(tr.edit_todo_aria_pat, &t.name), | |
| 140 | 146 | name: t.name.clone(), |
| 141 | 147 | color: colors.get(&t.category_id).copied().unwrap_or("#a19786"), |
| 142 | 148 | meta, |
| @@ -188,9 +194,10 @@ pub async fn todos_page( | ||
| 188 | 194 | let open = select(STATE_TODO, true, open_on); |
| 189 | 195 | let later = select(STATE_TODO, false, false); |
| 190 | 196 | |
| 191 | - let shell = shell(&state, &ctx, "Tâches", "todos", toast_of(q.ok.as_deref())).await?; | |
| 197 | + let shell = shell(&state, &ctx, tr.title_todos, "todos", toast_of(tr, q.ok.as_deref())).await?; | |
| 192 | 198 | Ok(HtmlTemplate(TodosPage { |
| 193 | 199 | csrf: ctx.user.csrf_token(&state), |
| 200 | + empty_body: p1(tr.todos_empty_body_pat, &ctx.team_name), | |
| 194 | 201 | empty: rows.is_empty(), |
| 195 | 202 | has_doing, |
| 196 | 203 | has_open, |
| @@ -235,11 +242,17 @@ pub struct TodoFormPage { | ||
| 235 | 242 | pub error: Option<&'static str>, |
| 236 | 243 | } |
| 237 | 244 | |
| 245 | +#[derive(Deserialize)] | |
| 246 | +pub struct FormQuery { | |
| 247 | + err: Option<String>, | |
| 248 | +} | |
| 249 | + | |
| 238 | 250 | pub async fn todo_new( |
| 239 | 251 | State(state): State<AppState>, |
| 240 | 252 | ctx: ActiveTeam, |
| 253 | + Query(q): Query<FormQuery>, | |
| 241 | 254 | ) -> Result<HtmlTemplate<TodoFormPage>, AppError> { |
| 242 | - form_page(&state, &ctx, None, None).await | |
| 255 | + form_page(&state, &ctx, None, None, q.err.as_deref()).await | |
| 243 | 256 | } |
| 244 | 257 | |
| 245 | 258 | pub async fn todo_edit( |
| @@ -251,7 +264,7 @@ pub async fn todo_edit( | ||
| 251 | 264 | .await? |
| 252 | 265 | .ok_or(AppError::NotFound)?; |
| 253 | 266 | let assigned = todos::assignees_of(&state.pool, id).await?; |
| 254 | - form_page(&state, &ctx, Some(todo), Some(assigned)).await | |
| 267 | + form_page(&state, &ctx, Some(todo), Some(assigned), None).await | |
| 255 | 268 | } |
| 256 | 269 | |
| 257 | 270 | async fn form_page( |
| @@ -259,13 +272,15 @@ async fn form_page( | ||
| 259 | 272 | ctx: &ActiveTeam, |
| 260 | 273 | todo: Option<TodoRow>, |
| 261 | 274 | assigned: Option<Vec<Uuid>>, |
| 275 | + err: Option<&str>, | |
| 262 | 276 | ) -> Result<HtmlTemplate<TodoFormPage>, AppError> { |
| 263 | 277 | let all = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 264 | 278 | let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>()); |
| 265 | 279 | let date = todo.as_ref().map(|t| t.activation_date).unwrap_or_else(paris_today); |
| 266 | 280 | let selected_cat = todo.as_ref().map(|t| t.category_id); |
| 267 | 281 | let assigned = assigned.unwrap_or_default(); |
| 268 | - let title = if todo.is_some() { "Modifier la tâche" } else { "Nouvelle tâche" }; | |
| 282 | + let tr = ctx.tr(); | |
| 283 | + let title = if todo.is_some() { tr.title_edit_todo } else { tr.title_new_todo }; | |
| 269 | 284 | let shell = shell(state, ctx, title, "todos", None).await?; |
| 270 | 285 | Ok(HtmlTemplate(TodoFormPage { |
| 271 | 286 | csrf: ctx.user.csrf_token(state), |
| @@ -282,17 +297,17 @@ async fn form_page( | ||
| 282 | 297 | }) |
| 283 | 298 | .collect(), |
| 284 | 299 | date, |
| 285 | - date_label: day_label(date), | |
| 300 | + date_label: day_label(ctx.lang, date), | |
| 286 | 301 | members: teams::members(&state.pool, ctx.team_id) |
| 287 | 302 | .await? |
| 288 | 303 | .into_iter() |
| 289 | 304 | .map(|m| MemberChip { |
| 290 | 305 | selected: assigned.contains(&m.id), |
| 291 | - name: if m.id == ctx.user.id { format!("{} (toi)", m.username) } else { m.username }, | |
| 306 | + name: if m.id == ctx.user.id { format!("{}{}", m.username, tr.you_suffix) } else { m.username }, | |
| 292 | 307 | id: m.id, |
| 293 | 308 | }) |
| 294 | 309 | .collect(), |
| 295 | - error: None, | |
| 310 | + error: (err == Some("incomplet")).then_some(tr.err_todo_incomplete), | |
| 296 | 311 | shell, |
| 297 | 312 | })) |
| 298 | 313 | } |
| @@ -348,7 +363,7 @@ pub async fn todo_create( | ||
| 348 | 363 | return Ok(Redirect::to("/taches/nouvelle?err=incomplet")); |
| 349 | 364 | } |
| 350 | 365 | match todos::create(&state.pool, ctx.team_id, ctx.user.id, cat, &f.name, date, &f.assignees).await? { |
| 351 | - 0 => Err(AppError::BadRequest("catégorie inconnue".into())), | |
| 366 | + 0 => Err(AppError::BadRequest("bad category".into())), | |
| 352 | 367 | _ => Ok(Redirect::to("/taches?ok=creee")), |
| 353 | 368 | } |
| 354 | 369 | } |
| @@ -499,12 +514,13 @@ pub async fn todo_done_page( | ||
| 499 | 514 | let minutes = q.minutes.unwrap_or(30).max(1); |
| 500 | 515 | let date = q.date.unwrap_or(today).min(today); |
| 501 | 516 | let next = q.prochaine.unwrap_or(proposal.date); |
| 517 | + let tr = ctx.tr(); | |
| 502 | 518 | let rhythm = match proposal.rhythm_days { |
| 503 | - Some(d) => format!("Rythme constaté : tous les ~{d} jour{}", if d > 1 { "s" } else { "" }), | |
| 504 | - None => format!("Pas encore de rythme constaté — proposition à {} jours.", proposal.days), | |
| 519 | + Some(d) => tr.rhythm_every(d), | |
| 520 | + None => p1(tr.rhythm_none_pat, proposal.days), | |
| 505 | 521 | }; |
| 506 | 522 | |
| 507 | - let shell = shell(&state, &ctx, "Tâche effectuée", "todos", None).await?; | |
| 523 | + let shell = shell(&state, &ctx, tr.title_todo_done, "todos", None).await?; | |
| 508 | 524 | Ok(HtmlTemplate(TodoDonePage { |
| 509 | 525 | csrf: ctx.user.csrf_token(&state), |
| 510 | 526 | id, |
| @@ -534,7 +550,7 @@ pub async fn todo_done_page( | ||
| 534 | 550 | day_prev: done_href(id, minutes, date - Duration::days(1), next), |
| 535 | 551 | day_next: (date < today).then(|| done_href(id, minutes, date + Duration::days(1), next)), |
| 536 | 552 | next, |
| 537 | - next_label: day_label(next), | |
| 553 | + next_label: day_label(ctx.lang, next), | |
| 538 | 554 | next_prev: done_href(id, minutes, date, next - Duration::days(1)), |
| 539 | 555 | next_next: done_href(id, minutes, date, next + Duration::days(1)), |
| 540 | 556 | rhythm, |
| @@ -14,6 +14,7 @@ use crate::db::categories; | |||
| 14 | use crate::db::teams; | 14 | use crate::db::teams; |
| 15 | use crate::db::todos::{self, STATE_IN_PROGRESS, STATE_TODO, TodoRow}; | 15 | use crate::db::todos::{self, STATE_IN_PROGRESS, STATE_TODO, TodoRow}; |
| 16 | use crate::error::AppError; | 16 | use crate::error::AppError; |
| 17 | +use crate::i18n::{Tr, p1, p2}; | ||
| 17 | use crate::period::{day_label, day_month, paris_today}; | 18 | use crate::period::{day_label, day_month, paris_today}; |
| 18 | use crate::render::{HtmlTemplate, Shell}; | 19 | use crate::render::{HtmlTemplate, Shell}; |
| 19 | use crate::routes::shell; | 20 | use crate::routes::shell; |
| @@ -33,6 +34,8 @@ pub struct AsgChip { | |||
| 33 | pub struct TodoCard { | 34 | pub struct TodoCard { |
| 34 | pub id: Uuid, | 35 | pub id: Uuid, |
| 35 | pub name: String, | 36 | pub name: String, |
| 37 | + /// « Modifier la tâche X » — le verbe se place différemment selon la langue. | ||
| 38 | + pub edit_aria: String, | ||
| 36 | pub color: &'static str, | 39 | pub color: &'static str, |
| 37 | /// « pris par julien · 5 août », « depuis le 3 août », « à partir du… » | 40 | /// « pris par julien · 5 août », « depuis le 3 août », « à partir du… » |
| 38 | pub meta: String, | 41 | pub meta: String, |
| @@ -53,6 +56,7 @@ pub struct TodoCard { | |||
| 53 | pub struct TodosPage { | 56 | pub struct TodosPage { |
| 54 | pub shell: Shell, | 57 | pub shell: Shell, |
| 55 | pub csrf: String, | 58 | pub csrf: String, |
| 59 | + pub empty_body: String, | ||
| 56 | pub doing: Vec<TodoCard>, | 60 | pub doing: Vec<TodoCard>, |
| 57 | pub open: Vec<TodoCard>, | 61 | pub open: Vec<TodoCard>, |
| 58 | pub later: Vec<TodoCard>, | 62 | pub later: Vec<TodoCard>, |
| @@ -74,16 +78,16 @@ pub struct ListQuery { | |||
| 74 | ok: Option<String>, | 78 | ok: Option<String>, |
| 75 | } | 79 | } |
| 76 | 80 | ||
| 77 | -fn toast_of(ok: Option<&str>) -> Option<String> { | 81 | +fn toast_of(tr: &Tr, ok: Option<&str>) -> Option<String> { |
| 78 | Some( | 82 | Some( |
| 79 | match ok? { | 83 | match ok? { |
| 80 | - "creee" => "Tâche créée ✓", | 84 | + "creee" => tr.toast_todo_created, |
| 81 | - "modifiee" => "Tâche modifiée ✓", | 85 | + "modifiee" => tr.toast_todo_edited, |
| 82 | - "supprimee" => "Tâche supprimée", | 86 | + "supprimee" => tr.toast_todo_deleted, |
| 83 | - "prise" => "Tâche prise ✓", | 87 | + "prise" => tr.toast_todo_taken, |
| 84 | - "relachee" => "Tâche relâchée", | 88 | + "relachee" => tr.toast_todo_released, |
| 85 | - "faite" => "Tâche faite ✓", | 89 | + "faite" => tr.toast_todo_done, |
| 86 | - "faite_reprog" => "Faite et reprogrammée ✓", | 90 | + "faite_reprog" => tr.toast_todo_done_reprog, |
| 87 | _ => return None, | 91 | _ => return None, |
| 88 | } | 92 | } |
| 89 | .to_string(), | 93 | .to_string(), |
| @@ -105,6 +109,7 @@ pub async fn todos_page( | |||
| 105 | Query(q): Query<ListQuery>, | 109 | Query(q): Query<ListQuery>, |
| 106 | ) -> Result<HtmlTemplate<TodosPage>, AppError> { | 110 | ) -> Result<HtmlTemplate<TodosPage>, AppError> { |
| 107 | let today = paris_today(); | 111 | let today = paris_today(); |
| 112 | + let tr = ctx.tr(); | ||
| 108 | let me = ctx.user.id; | 113 | let me = ctx.user.id; |
| 109 | let doing_on = q.encours.as_deref() == Some("moi"); | 114 | let doing_on = q.encours.as_deref() == Some("moi"); |
| 110 | let open_on = q.afaire.as_deref() == Some("moi"); | 115 | let open_on = q.afaire.as_deref() == Some("moi"); |
| @@ -125,18 +130,19 @@ pub async fn todos_page( | |||
| 125 | let meta = if t.state_id == STATE_IN_PROGRESS { | 130 | let meta = if t.state_id == STATE_IN_PROGRESS { |
| 126 | let on = t | 131 | let on = t |
| 127 | .taken_at | 132 | .taken_at |
| 128 | - .map(|at| day_month(at.with_timezone(&Paris).date_naive())) | 133 | + .map(|at| day_month(ctx.lang, at.with_timezone(&Paris).date_naive())) |
| 129 | .unwrap_or_default(); | 134 | .unwrap_or_default(); |
| 130 | - format!("pris par {} · {on}", t.taker_name.as_deref().unwrap_or("?")) | 135 | + p2(tr.taken_by_pat, t.taker_name.as_deref().unwrap_or("?"), on) |
| 131 | } else if late { | 136 | } else if late { |
| 132 | - format!("depuis le {}", day_month(t.activation_date)) | 137 | + p1(tr.since_pat, day_month(ctx.lang, t.activation_date)) |
| 133 | } else if t.activation_date == today { | 138 | } else if t.activation_date == today { |
| 134 | - "activée aujourd'hui".to_string() | 139 | + tr.activated_today.to_string() |
| 135 | } else { | 140 | } else { |
| 136 | - format!("à partir du {}", day_month(t.activation_date)) | 141 | + p1(tr.from_pat, day_month(ctx.lang, t.activation_date)) |
| 137 | }; | 142 | }; |
| 138 | TodoCard { | 143 | TodoCard { |
| 139 | id: t.id, | 144 | id: t.id, |
| 145 | + edit_aria: p1(tr.edit_todo_aria_pat, &t.name), | ||
| 140 | name: t.name.clone(), | 146 | name: t.name.clone(), |
| 141 | color: colors.get(&t.category_id).copied().unwrap_or("#a19786"), | 147 | color: colors.get(&t.category_id).copied().unwrap_or("#a19786"), |
| 142 | meta, | 148 | meta, |
| @@ -188,9 +194,10 @@ pub async fn todos_page( | |||
| 188 | let open = select(STATE_TODO, true, open_on); | 194 | let open = select(STATE_TODO, true, open_on); |
| 189 | let later = select(STATE_TODO, false, false); | 195 | let later = select(STATE_TODO, false, false); |
| 190 | 196 | ||
| 191 | - let shell = shell(&state, &ctx, "Tâches", "todos", toast_of(q.ok.as_deref())).await?; | 197 | + let shell = shell(&state, &ctx, tr.title_todos, "todos", toast_of(tr, q.ok.as_deref())).await?; |
| 192 | Ok(HtmlTemplate(TodosPage { | 198 | Ok(HtmlTemplate(TodosPage { |
| 193 | csrf: ctx.user.csrf_token(&state), | 199 | csrf: ctx.user.csrf_token(&state), |
| 200 | + empty_body: p1(tr.todos_empty_body_pat, &ctx.team_name), | ||
| 194 | empty: rows.is_empty(), | 201 | empty: rows.is_empty(), |
| 195 | has_doing, | 202 | has_doing, |
| 196 | has_open, | 203 | has_open, |
| @@ -235,11 +242,17 @@ pub struct TodoFormPage { | |||
| 235 | pub error: Option<&'static str>, | 242 | pub error: Option<&'static str>, |
| 236 | } | 243 | } |
| 237 | 244 | ||
| 245 | +#[derive(Deserialize)] | ||
| 246 | +pub struct FormQuery { | ||
| 247 | + err: Option<String>, | ||
| 248 | +} | ||
| 249 | + | ||
| 238 | pub async fn todo_new( | 250 | pub async fn todo_new( |
| 239 | State(state): State<AppState>, | 251 | State(state): State<AppState>, |
| 240 | ctx: ActiveTeam, | 252 | ctx: ActiveTeam, |
| 253 | + Query(q): Query<FormQuery>, | ||
| 241 | ) -> Result<HtmlTemplate<TodoFormPage>, AppError> { | 254 | ) -> Result<HtmlTemplate<TodoFormPage>, AppError> { |
| 242 | - form_page(&state, &ctx, None, None).await | 255 | + form_page(&state, &ctx, None, None, q.err.as_deref()).await |
| 243 | } | 256 | } |
| 244 | 257 | ||
| 245 | pub async fn todo_edit( | 258 | pub async fn todo_edit( |
| @@ -251,7 +264,7 @@ pub async fn todo_edit( | |||
| 251 | .await? | 264 | .await? |
| 252 | .ok_or(AppError::NotFound)?; | 265 | .ok_or(AppError::NotFound)?; |
| 253 | let assigned = todos::assignees_of(&state.pool, id).await?; | 266 | let assigned = todos::assignees_of(&state.pool, id).await?; |
| 254 | - form_page(&state, &ctx, Some(todo), Some(assigned)).await | 267 | + form_page(&state, &ctx, Some(todo), Some(assigned), None).await |
| 255 | } | 268 | } |
| 256 | 269 | ||
| 257 | async fn form_page( | 270 | async fn form_page( |
| @@ -259,13 +272,15 @@ async fn form_page( | |||
| 259 | ctx: &ActiveTeam, | 272 | ctx: &ActiveTeam, |
| 260 | todo: Option<TodoRow>, | 273 | todo: Option<TodoRow>, |
| 261 | assigned: Option<Vec<Uuid>>, | 274 | assigned: Option<Vec<Uuid>>, |
| 275 | + err: Option<&str>, | ||
| 262 | ) -> Result<HtmlTemplate<TodoFormPage>, AppError> { | 276 | ) -> Result<HtmlTemplate<TodoFormPage>, AppError> { |
| 263 | let all = categories::list_by_creation(&state.pool, ctx.team_id).await?; | 277 | let all = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 264 | let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>()); | 278 | let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>()); |
| 265 | let date = todo.as_ref().map(|t| t.activation_date).unwrap_or_else(paris_today); | 279 | let date = todo.as_ref().map(|t| t.activation_date).unwrap_or_else(paris_today); |
| 266 | let selected_cat = todo.as_ref().map(|t| t.category_id); | 280 | let selected_cat = todo.as_ref().map(|t| t.category_id); |
| 267 | let assigned = assigned.unwrap_or_default(); | 281 | let assigned = assigned.unwrap_or_default(); |
| 268 | - let title = if todo.is_some() { "Modifier la tâche" } else { "Nouvelle tâche" }; | 282 | + let tr = ctx.tr(); |
| 283 | + let title = if todo.is_some() { tr.title_edit_todo } else { tr.title_new_todo }; | ||
| 269 | let shell = shell(state, ctx, title, "todos", None).await?; | 284 | let shell = shell(state, ctx, title, "todos", None).await?; |
| 270 | Ok(HtmlTemplate(TodoFormPage { | 285 | Ok(HtmlTemplate(TodoFormPage { |
| 271 | csrf: ctx.user.csrf_token(state), | 286 | csrf: ctx.user.csrf_token(state), |
| @@ -282,17 +297,17 @@ async fn form_page( | |||
| 282 | }) | 297 | }) |
| 283 | .collect(), | 298 | .collect(), |
| 284 | date, | 299 | date, |
| 285 | - date_label: day_label(date), | 300 | + date_label: day_label(ctx.lang, date), |
| 286 | members: teams::members(&state.pool, ctx.team_id) | 301 | members: teams::members(&state.pool, ctx.team_id) |
| 287 | .await? | 302 | .await? |
| 288 | .into_iter() | 303 | .into_iter() |
| 289 | .map(|m| MemberChip { | 304 | .map(|m| MemberChip { |
| 290 | selected: assigned.contains(&m.id), | 305 | selected: assigned.contains(&m.id), |
| 291 | - name: if m.id == ctx.user.id { format!("{} (toi)", m.username) } else { m.username }, | 306 | + name: if m.id == ctx.user.id { format!("{}{}", m.username, tr.you_suffix) } else { m.username }, |
| 292 | id: m.id, | 307 | id: m.id, |
| 293 | }) | 308 | }) |
| 294 | .collect(), | 309 | .collect(), |
| 295 | - error: None, | 310 | + error: (err == Some("incomplet")).then_some(tr.err_todo_incomplete), |
| 296 | shell, | 311 | shell, |
| 297 | })) | 312 | })) |
| 298 | } | 313 | } |
| @@ -348,7 +363,7 @@ pub async fn todo_create( | |||
| 348 | return Ok(Redirect::to("/taches/nouvelle?err=incomplet")); | 363 | return Ok(Redirect::to("/taches/nouvelle?err=incomplet")); |
| 349 | } | 364 | } |
| 350 | match todos::create(&state.pool, ctx.team_id, ctx.user.id, cat, &f.name, date, &f.assignees).await? { | 365 | match todos::create(&state.pool, ctx.team_id, ctx.user.id, cat, &f.name, date, &f.assignees).await? { |
| 351 | - 0 => Err(AppError::BadRequest("catégorie inconnue".into())), | 366 | + 0 => Err(AppError::BadRequest("bad category".into())), |
| 352 | _ => Ok(Redirect::to("/taches?ok=creee")), | 367 | _ => Ok(Redirect::to("/taches?ok=creee")), |
| 353 | } | 368 | } |
| 354 | } | 369 | } |
| @@ -499,12 +514,13 @@ pub async fn todo_done_page( | |||
| 499 | let minutes = q.minutes.unwrap_or(30).max(1); | 514 | let minutes = q.minutes.unwrap_or(30).max(1); |
| 500 | let date = q.date.unwrap_or(today).min(today); | 515 | let date = q.date.unwrap_or(today).min(today); |
| 501 | let next = q.prochaine.unwrap_or(proposal.date); | 516 | let next = q.prochaine.unwrap_or(proposal.date); |
| 517 | + let tr = ctx.tr(); | ||
| 502 | let rhythm = match proposal.rhythm_days { | 518 | let rhythm = match proposal.rhythm_days { |
| 503 | - Some(d) => format!("Rythme constaté : tous les ~{d} jour{}", if d > 1 { "s" } else { "" }), | 519 | + Some(d) => tr.rhythm_every(d), |
| 504 | - None => format!("Pas encore de rythme constaté — proposition à {} jours.", proposal.days), | 520 | + None => p1(tr.rhythm_none_pat, proposal.days), |
| 505 | }; | 521 | }; |
| 506 | 522 | ||
| 507 | - let shell = shell(&state, &ctx, "Tâche effectuée", "todos", None).await?; | 523 | + let shell = shell(&state, &ctx, tr.title_todo_done, "todos", None).await?; |
| 508 | Ok(HtmlTemplate(TodoDonePage { | 524 | Ok(HtmlTemplate(TodoDonePage { |
| 509 | csrf: ctx.user.csrf_token(&state), | 525 | csrf: ctx.user.csrf_token(&state), |
| 510 | id, | 526 | id, |
| @@ -534,7 +550,7 @@ pub async fn todo_done_page( | |||
| 534 | day_prev: done_href(id, minutes, date - Duration::days(1), next), | 550 | day_prev: done_href(id, minutes, date - Duration::days(1), next), |
| 535 | day_next: (date < today).then(|| done_href(id, minutes, date + Duration::days(1), next)), | 551 | day_next: (date < today).then(|| done_href(id, minutes, date + Duration::days(1), next)), |
| 536 | next, | 552 | next, |
| 537 | - next_label: day_label(next), | 553 | + next_label: day_label(ctx.lang, next), |
| 538 | next_prev: done_href(id, minutes, date, next - Duration::days(1)), | 554 | next_prev: done_href(id, minutes, date, next - Duration::days(1)), |
| 539 | next_next: done_href(id, minutes, date, next + Duration::days(1)), | 555 | next_next: done_href(id, minutes, date, next + Duration::days(1)), |
| 540 | rhythm, | 556 | rhythm, |
modified
src/team.rs +19 -1 | @@ -7,8 +7,9 @@ use uuid::Uuid; | ||
| 7 | 7 | |
| 8 | 8 | use crate::auth::SESSION_SECONDS; |
| 9 | 9 | use crate::auth::extractor::{AuthRedirect, AuthUser, authenticate}; |
| 10 | -use crate::db::teams; | |
| 10 | +use crate::db::{teams, users}; | |
| 11 | 11 | use crate::error::AppError; |
| 12 | +use crate::i18n::{Lang, Tr}; | |
| 12 | 13 | use crate::state::AppState; |
| 13 | 14 | |
| 14 | 15 | /// Équipe active par appareil — un simple UUID non signé : l'appartenance |
| @@ -36,10 +37,19 @@ pub fn clear_team_cookie() -> Cookie<'static> { | ||
| 36 | 37 | /// appartenance (sans réécrire le cookie). |
| 37 | 38 | pub struct ActiveTeam { |
| 38 | 39 | pub user: AuthUser, |
| 40 | + pub username: String, | |
| 41 | + /// Langue de l'interface — par utilisateur, stockée en base. | |
| 42 | + pub lang: Lang, | |
| 39 | 43 | pub team_id: Uuid, |
| 40 | 44 | pub team_name: String, |
| 41 | 45 | } |
| 42 | 46 | |
| 47 | +impl ActiveTeam { | |
| 48 | + pub fn tr(&self) -> &'static Tr { | |
| 49 | + self.lang.tr() | |
| 50 | + } | |
| 51 | +} | |
| 52 | + | |
| 43 | 53 | pub enum TeamRejection { |
| 44 | 54 | Auth(AuthRedirect), |
| 45 | 55 | App(AppError), |
| @@ -68,6 +78,10 @@ impl FromRequestParts<AppState> for ActiveTeam { | ||
| 68 | 78 | state: &AppState, |
| 69 | 79 | ) -> Result<Self, TeamRejection> { |
| 70 | 80 | let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?; |
| 81 | + let (username, lang_code) = users::profile(&state.pool, user.id) | |
| 82 | + .await? | |
| 83 | + .unwrap_or_default(); | |
| 84 | + let lang = Lang::from_code(&lang_code).unwrap_or(Lang::Fr); | |
| 71 | 85 | let jar = CookieJar::from_headers(&parts.headers); |
| 72 | 86 | let wanted = jar |
| 73 | 87 | .get(TEAM_COOKIE) |
| @@ -77,6 +91,8 @@ impl FromRequestParts<AppState> for ActiveTeam { | ||
| 77 | 91 | if let Some((id, name)) = teams::membership(&state.pool, user.id, team_id).await? { |
| 78 | 92 | return Ok(ActiveTeam { |
| 79 | 93 | user, |
| 94 | + username, | |
| 95 | + lang, | |
| 80 | 96 | team_id: id, |
| 81 | 97 | team_name: name, |
| 82 | 98 | }); |
| @@ -85,6 +101,8 @@ impl FromRequestParts<AppState> for ActiveTeam { | ||
| 85 | 101 | match teams::oldest_membership(&state.pool, user.id).await? { |
| 86 | 102 | Some((id, name)) => Ok(ActiveTeam { |
| 87 | 103 | user, |
| 104 | + username, | |
| 105 | + lang, | |
| 88 | 106 | team_id: id, |
| 89 | 107 | team_name: name, |
| 90 | 108 | }), |
| @@ -7,8 +7,9 @@ use uuid::Uuid; | |||
| 7 | 7 | ||
| 8 | use crate::auth::SESSION_SECONDS; | 8 | use crate::auth::SESSION_SECONDS; |
| 9 | use crate::auth::extractor::{AuthRedirect, AuthUser, authenticate}; | 9 | use crate::auth::extractor::{AuthRedirect, AuthUser, authenticate}; |
| 10 | -use crate::db::teams; | 10 | +use crate::db::{teams, users}; |
| 11 | use crate::error::AppError; | 11 | use crate::error::AppError; |
| 12 | +use crate::i18n::{Lang, Tr}; | ||
| 12 | use crate::state::AppState; | 13 | use crate::state::AppState; |
| 13 | 14 | ||
| 14 | /// Équipe active par appareil — un simple UUID non signé : l'appartenance | 15 | /// Équipe active par appareil — un simple UUID non signé : l'appartenance |
| @@ -36,10 +37,19 @@ pub fn clear_team_cookie() -> Cookie<'static> { | |||
| 36 | /// appartenance (sans réécrire le cookie). | 37 | /// appartenance (sans réécrire le cookie). |
| 37 | pub struct ActiveTeam { | 38 | pub struct ActiveTeam { |
| 38 | pub user: AuthUser, | 39 | pub user: AuthUser, |
| 40 | + pub username: String, | ||
| 41 | + /// Langue de l'interface — par utilisateur, stockée en base. | ||
| 42 | + pub lang: Lang, | ||
| 39 | pub team_id: Uuid, | 43 | pub team_id: Uuid, |
| 40 | pub team_name: String, | 44 | pub team_name: String, |
| 41 | } | 45 | } |
| 42 | 46 | ||
| 47 | +impl ActiveTeam { | ||
| 48 | + pub fn tr(&self) -> &'static Tr { | ||
| 49 | + self.lang.tr() | ||
| 50 | + } | ||
| 51 | +} | ||
| 52 | + | ||
| 43 | pub enum TeamRejection { | 53 | pub enum TeamRejection { |
| 44 | Auth(AuthRedirect), | 54 | Auth(AuthRedirect), |
| 45 | App(AppError), | 55 | App(AppError), |
| @@ -68,6 +78,10 @@ impl FromRequestParts<AppState> for ActiveTeam { | |||
| 68 | state: &AppState, | 78 | state: &AppState, |
| 69 | ) -> Result<Self, TeamRejection> { | 79 | ) -> Result<Self, TeamRejection> { |
| 70 | let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?; | 80 | let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?; |
| 81 | + let (username, lang_code) = users::profile(&state.pool, user.id) | ||
| 82 | + .await? | ||
| 83 | + .unwrap_or_default(); | ||
| 84 | + let lang = Lang::from_code(&lang_code).unwrap_or(Lang::Fr); | ||
| 71 | let jar = CookieJar::from_headers(&parts.headers); | 85 | let jar = CookieJar::from_headers(&parts.headers); |
| 72 | let wanted = jar | 86 | let wanted = jar |
| 73 | .get(TEAM_COOKIE) | 87 | .get(TEAM_COOKIE) |
| @@ -77,6 +91,8 @@ impl FromRequestParts<AppState> for ActiveTeam { | |||
| 77 | if let Some((id, name)) = teams::membership(&state.pool, user.id, team_id).await? { | 91 | if let Some((id, name)) = teams::membership(&state.pool, user.id, team_id).await? { |
| 78 | return Ok(ActiveTeam { | 92 | return Ok(ActiveTeam { |
| 79 | user, | 93 | user, |
| 94 | + username, | ||
| 95 | + lang, | ||
| 80 | team_id: id, | 96 | team_id: id, |
| 81 | team_name: name, | 97 | team_name: name, |
| 82 | }); | 98 | }); |
| @@ -85,6 +101,8 @@ impl FromRequestParts<AppState> for ActiveTeam { | |||
| 85 | match teams::oldest_membership(&state.pool, user.id).await? { | 101 | match teams::oldest_membership(&state.pool, user.id).await? { |
| 86 | Some((id, name)) => Ok(ActiveTeam { | 102 | Some((id, name)) => Ok(ActiveTeam { |
| 87 | user, | 103 | user, |
| 104 | + username, | ||
| 105 | + lang, | ||
| 88 | team_id: id, | 106 | team_id: id, |
| 89 | team_name: name, | 107 | team_name: name, |
| 90 | }), | 108 | }), |
modified
templates/add_step1.html +5 -5 | @@ -3,17 +3,17 @@ | ||
| 3 | 3 | {% if let Some(m) = measured %} |
| 4 | 4 | <div class="measure-card"> |
| 5 | 5 | <p>{{ m.title }}</p> |
| 6 | - {% if m.warning %}<p class="warn">Timer oublié ? Tu pourras corriger la durée à l'étape suivante.</p>{% endif %} | |
| 6 | + {% if m.warning %}<p class="warn">{{ shell.tr.forgot_timer }}</p>{% endif %} | |
| 7 | 7 | <form method="post" action="/timer/annuler" style="margin:var(--space-2) 0 0"> |
| 8 | 8 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 9 | - <button type="submit" class="btn btn-ghost" style="font-size:13px;padding-left:0;color:var(--color-accent-900)">Annuler ce temps</button> | |
| 9 | + <button type="submit" class="btn btn-ghost" style="font-size:13px;padding-left:0;color:var(--color-accent-900)">{{ shell.tr.cancel_measured }}</button> | |
| 10 | 10 | </form> |
| 11 | 11 | </div> |
| 12 | 12 | {% endif %} |
| 13 | 13 | {% if categories.is_empty() %} |
| 14 | -<p class="step-hint">Aucune catégorie pour l'instant — commence par en créer une.</p> | |
| 14 | +<p class="step-hint">{{ shell.tr.no_categories_yet }}</p> | |
| 15 | 15 | {% else %} |
| 16 | -<p class="step-hint">Étape 1 sur 2 — choisis la catégorie</p> | |
| 16 | +<p class="step-hint">{{ shell.tr.step1_hint }}</p> | |
| 17 | 17 | <div class="category-pick"> |
| 18 | 18 | {% for t in categories %} |
| 19 | 19 | <a href="{{ t.href }}"> |
| @@ -24,5 +24,5 @@ | ||
| 24 | 24 | {% endfor %} |
| 25 | 25 | </div> |
| 26 | 26 | {% endif %} |
| 27 | -<a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/parametres/categories">Gérer les catégories</a> | |
| 27 | +<a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/parametres/categories">{{ shell.tr.manage_categories }}</a> | |
| 28 | 28 | {% endblock %} |
| @@ -3,17 +3,17 @@ | |||
| 3 | {% if let Some(m) = measured %} | 3 | {% if let Some(m) = measured %} |
| 4 | <div class="measure-card"> | 4 | <div class="measure-card"> |
| 5 | <p>{{ m.title }}</p> | 5 | <p>{{ m.title }}</p> |
| 6 | - {% if m.warning %}<p class="warn">Timer oublié ? Tu pourras corriger la durée à l'étape suivante.</p>{% endif %} | 6 | + {% if m.warning %}<p class="warn">{{ shell.tr.forgot_timer }}</p>{% endif %} |
| 7 | <form method="post" action="/timer/annuler" style="margin:var(--space-2) 0 0"> | 7 | <form method="post" action="/timer/annuler" style="margin:var(--space-2) 0 0"> |
| 8 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | 8 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 9 | - <button type="submit" class="btn btn-ghost" style="font-size:13px;padding-left:0;color:var(--color-accent-900)">Annuler ce temps</button> | 9 | + <button type="submit" class="btn btn-ghost" style="font-size:13px;padding-left:0;color:var(--color-accent-900)">{{ shell.tr.cancel_measured }}</button> |
| 10 | </form> | 10 | </form> |
| 11 | </div> | 11 | </div> |
| 12 | {% endif %} | 12 | {% endif %} |
| 13 | {% if categories.is_empty() %} | 13 | {% if categories.is_empty() %} |
| 14 | -<p class="step-hint">Aucune catégorie pour l'instant — commence par en créer une.</p> | 14 | +<p class="step-hint">{{ shell.tr.no_categories_yet }}</p> |
| 15 | {% else %} | 15 | {% else %} |
| 16 | -<p class="step-hint">Étape 1 sur 2 — choisis la catégorie</p> | 16 | +<p class="step-hint">{{ shell.tr.step1_hint }}</p> |
| 17 | <div class="category-pick"> | 17 | <div class="category-pick"> |
| 18 | {% for t in categories %} | 18 | {% for t in categories %} |
| 19 | <a href="{{ t.href }}"> | 19 | <a href="{{ t.href }}"> |
| @@ -24,5 +24,5 @@ | |||
| 24 | {% endfor %} | 24 | {% endfor %} |
| 25 | </div> | 25 | </div> |
| 26 | {% endif %} | 26 | {% endif %} |
| 27 | -<a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/parametres/categories">Gérer les catégories</a> | 27 | +<a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/parametres/categories">{{ shell.tr.manage_categories }}</a> |
| 28 | {% endblock %} | 28 | {% endblock %} |
modified
templates/add_step2.html +9 -9 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 2 | {% block content %} |
| 3 | -<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0" href="/temps/nouveau">← Changer de catégorie</a> | |
| 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0" href="/temps/nouveau">{{ shell.tr.change_category }}</a> | |
| 4 | 4 | <div class="picked"> |
| 5 | 5 | <span class="dot dot-16" style="background:{{ picked_color }}"></span> |
| 6 | 6 | <span class="pname">{{ picked_name }}</span> |
| @@ -10,20 +10,20 @@ | ||
| 10 | 10 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 11 | 11 | <input type="hidden" name="categorie" value="{{ categorie }}"> |
| 12 | 12 | |
| 13 | - <label class="mini-label" for="minutes">Durée</label> | |
| 13 | + <label class="mini-label" for="minutes">{{ shell.tr.duration_label }}</label> | |
| 14 | 14 | <div class="duration-row"> |
| 15 | 15 | <div class="stepper"> |
| 16 | - <a class="stepper-btn" href="{{ minus_href }}" data-step="-5" aria-label="Cinq minutes de moins"> | |
| 16 | + <a class="stepper-btn" href="{{ minus_href }}" data-step="-5" aria-label="{{ shell.tr.five_less }}"> | |
| 17 | 17 | <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg> |
| 18 | 18 | </a> |
| 19 | 19 | <input class="input minutes-input" id="minutes" name="minutes" type="number" inputmode="numeric" |
| 20 | 20 | min="1" required value="{{ minutes }}"> |
| 21 | - <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="Cinq minutes de plus"> | |
| 21 | + <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="{{ shell.tr.five_more }}"> | |
| 22 | 22 | <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg> |
| 23 | 23 | </a> |
| 24 | 24 | </div> |
| 25 | 25 | <div> |
| 26 | - <div class="unit">minutes</div> | |
| 26 | + <div class="unit">{{ shell.tr.minutes_unit }}</div> | |
| 27 | 27 | <div class="pretty" id="minutes-pretty">{{ minutes_pretty }}</div> |
| 28 | 28 | </div> |
| 29 | 29 | </div> |
| @@ -75,14 +75,14 @@ | ||
| 75 | 75 | {% endfor %} |
| 76 | 76 | </div> |
| 77 | 77 | |
| 78 | - <label class="mini-label" for="date">Date</label> | |
| 78 | + <label class="mini-label" for="date">{{ shell.tr.date_label }}</label> | |
| 79 | 79 | <div class="date-row"> |
| 80 | - <a class="btn btn-secondary btn-icon" href="{{ day_prev }}" aria-label="Jour précédent"> | |
| 80 | + <a class="btn btn-secondary btn-icon" href="{{ day_prev }}" aria-label="{{ shell.tr.day_prev }}"> | |
| 81 | 81 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> |
| 82 | 82 | </a> |
| 83 | 83 | <input class="input" id="date" name="date" type="date" value="{{ date }}" required> |
| 84 | 84 | {% if let Some(next) = day_next %} |
| 85 | - <a class="btn btn-secondary btn-icon" href="{{ next }}" aria-label="Jour suivant"> | |
| 85 | + <a class="btn btn-secondary btn-icon" href="{{ next }}" aria-label="{{ shell.tr.day_next }}"> | |
| 86 | 86 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> |
| 87 | 87 | </a> |
| 88 | 88 | {% else %} |
| @@ -93,6 +93,6 @@ | ||
| 93 | 93 | </div> |
| 94 | 94 | <p class="date-label">{{ date_label }}</p> |
| 95 | 95 | |
| 96 | - <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px">Ajouter du temps</button> | |
| 96 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px">{{ shell.tr.title_add }}</button> | |
| 97 | 97 | </form> |
| 98 | 98 | {% endblock %} |
| @@ -1,6 +1,6 @@ | |||
| 1 | {% extends "app.html" %} | 1 | {% extends "app.html" %} |
| 2 | {% block content %} | 2 | {% block content %} |
| 3 | -<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0" href="/temps/nouveau">← Changer de catégorie</a> | 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0" href="/temps/nouveau">{{ shell.tr.change_category }}</a> |
| 4 | <div class="picked"> | 4 | <div class="picked"> |
| 5 | <span class="dot dot-16" style="background:{{ picked_color }}"></span> | 5 | <span class="dot dot-16" style="background:{{ picked_color }}"></span> |
| 6 | <span class="pname">{{ picked_name }}</span> | 6 | <span class="pname">{{ picked_name }}</span> |
| @@ -10,20 +10,20 @@ | |||
| 10 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 10 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 11 | <input type="hidden" name="categorie" value="{{ categorie }}"> | 11 | <input type="hidden" name="categorie" value="{{ categorie }}"> |
| 12 | 12 | ||
| 13 | - <label class="mini-label" for="minutes">Durée</label> | 13 | + <label class="mini-label" for="minutes">{{ shell.tr.duration_label }}</label> |
| 14 | <div class="duration-row"> | 14 | <div class="duration-row"> |
| 15 | <div class="stepper"> | 15 | <div class="stepper"> |
| 16 | - <a class="stepper-btn" href="{{ minus_href }}" data-step="-5" aria-label="Cinq minutes de moins"> | 16 | + <a class="stepper-btn" href="{{ minus_href }}" data-step="-5" aria-label="{{ shell.tr.five_less }}"> |
| 17 | <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg> | 17 | <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg> |
| 18 | </a> | 18 | </a> |
| 19 | <input class="input minutes-input" id="minutes" name="minutes" type="number" inputmode="numeric" | 19 | <input class="input minutes-input" id="minutes" name="minutes" type="number" inputmode="numeric" |
| 20 | min="1" required value="{{ minutes }}"> | 20 | min="1" required value="{{ minutes }}"> |
| 21 | - <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="Cinq minutes de plus"> | 21 | + <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="{{ shell.tr.five_more }}"> |
| 22 | <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg> | 22 | <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg> |
| 23 | </a> | 23 | </a> |
| 24 | </div> | 24 | </div> |
| 25 | <div> | 25 | <div> |
| 26 | - <div class="unit">minutes</div> | 26 | + <div class="unit">{{ shell.tr.minutes_unit }}</div> |
| 27 | <div class="pretty" id="minutes-pretty">{{ minutes_pretty }}</div> | 27 | <div class="pretty" id="minutes-pretty">{{ minutes_pretty }}</div> |
| 28 | </div> | 28 | </div> |
| 29 | </div> | 29 | </div> |
| @@ -75,14 +75,14 @@ | |||
| 75 | {% endfor %} | 75 | {% endfor %} |
| 76 | </div> | 76 | </div> |
| 77 | 77 | ||
| 78 | - <label class="mini-label" for="date">Date</label> | 78 | + <label class="mini-label" for="date">{{ shell.tr.date_label }}</label> |
| 79 | <div class="date-row"> | 79 | <div class="date-row"> |
| 80 | - <a class="btn btn-secondary btn-icon" href="{{ day_prev }}" aria-label="Jour précédent"> | 80 | + <a class="btn btn-secondary btn-icon" href="{{ day_prev }}" aria-label="{{ shell.tr.day_prev }}"> |
| 81 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> | 81 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> |
| 82 | </a> | 82 | </a> |
| 83 | <input class="input" id="date" name="date" type="date" value="{{ date }}" required> | 83 | <input class="input" id="date" name="date" type="date" value="{{ date }}" required> |
| 84 | {% if let Some(next) = day_next %} | 84 | {% if let Some(next) = day_next %} |
| 85 | - <a class="btn btn-secondary btn-icon" href="{{ next }}" aria-label="Jour suivant"> | 85 | + <a class="btn btn-secondary btn-icon" href="{{ next }}" aria-label="{{ shell.tr.day_next }}"> |
| 86 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | 86 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> |
| 87 | </a> | 87 | </a> |
| 88 | {% else %} | 88 | {% else %} |
| @@ -93,6 +93,6 @@ | |||
| 93 | </div> | 93 | </div> |
| 94 | <p class="date-label">{{ date_label }}</p> | 94 | <p class="date-label">{{ date_label }}</p> |
| 95 | 95 | ||
| 96 | - <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px">Ajouter du temps</button> | 96 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px">{{ shell.tr.title_add }}</button> |
| 97 | </form> | 97 | </form> |
| 98 | {% endblock %} | 98 | {% endblock %} |
modified
templates/app.html +15 -14 | @@ -1,10 +1,11 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | 2 | {% block title %}{{ shell.title }} · time-time{% endblock %} |
| 3 | +{% block lang %}{{ shell.lang_code }}{% endblock %} | |
| 3 | 4 | {% block body %} |
| 4 | 5 | <header class="app-header"> |
| 5 | 6 | <div class="app-topline"> |
| 6 | 7 | {% if !shell.teams.is_empty() %} |
| 7 | - <a class="team-pill" href="#equipes" aria-label="Changer d'équipe" style="--team-color:{{ shell.team_color }}"> | |
| 8 | + <a class="team-pill" href="#equipes" aria-label="{{ shell.tr.change_team }}" style="--team-color:{{ shell.team_color }}"> | |
| 8 | 9 | <span class="team-dot" style="background:{{ shell.team_color }}"></span> |
| 9 | 10 | {{ shell.team_name }} |
| 10 | 11 | <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"></path></svg> |
| @@ -26,7 +27,7 @@ | ||
| 26 | 27 | {% if t.running %} |
| 27 | 28 | <form class="timer-slot" method="post" action="/timer/arreter"> |
| 28 | 29 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 29 | - <button type="submit" class="timer-banner timer-running" aria-label="Arrêter le timer"> | |
| 30 | + <button type="submit" class="timer-banner timer-running" aria-label="{{ shell.tr.stop_timer_aria }}"> | |
| 30 | 31 | <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13.5" r="7.5"></circle><path d="M12 10v3.5l2.2 1.6"></path><path d="M9.4 2.5h5.2"></path></svg> |
| 31 | 32 | <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> |
| 32 | 33 | <span class="t-note">{{ t.note }}</span> |
| @@ -35,7 +36,7 @@ | ||
| 35 | 36 | {% else if t.foreign %} |
| 36 | 37 | <form class="timer-slot" method="post" action="/timer/arreter"> |
| 37 | 38 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 38 | - <button type="submit" class="timer-banner timer-waiting" aria-label="Enregistrer le temps mesuré (bascule d'équipe)"> | |
| 39 | + <button type="submit" class="timer-banner timer-waiting" aria-label="{{ shell.tr.record_measured_switch_aria }}"> | |
| 39 | 40 | <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5V12l3 1.8"></path></svg> |
| 40 | 41 | <span class="t-elapsed">{{ t.label }}</span> |
| 41 | 42 | <span class="t-note">{{ t.note }}</span> |
| @@ -43,7 +44,7 @@ | ||
| 43 | 44 | </form> |
| 44 | 45 | {% else %} |
| 45 | 46 | <div class="timer-slot"> |
| 46 | - <a class="timer-banner timer-waiting" href="/temps/nouveau" aria-label="Enregistrer le temps mesuré"> | |
| 47 | + <a class="timer-banner timer-waiting" href="/temps/nouveau" aria-label="{{ shell.tr.record_measured_aria }}"> | |
| 47 | 48 | <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5V12l3 1.8"></path></svg> |
| 48 | 49 | <span class="t-elapsed">{{ t.label }}</span> |
| 49 | 50 | </a> |
| @@ -53,19 +54,19 @@ | ||
| 53 | 54 | <form class="timer-slot" method="post" action="/timer/demarrer"> |
| 54 | 55 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 55 | 56 | <input type="hidden" name="retour" value="{{ shell.start_return }}"> |
| 56 | - <button type="submit" class="timer-banner timer-start" aria-label="Démarrer le timer"> | |
| 57 | + <button type="submit" class="timer-banner timer-start" aria-label="{{ shell.tr.start_timer_aria }}"> | |
| 57 | 58 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M10.2 8.6l5.6 3.4-5.6 3.4z"></path></svg> |
| 58 | - <span>Lancer le minuteur</span> | |
| 59 | + <span>{{ shell.tr.start_timer }}</span> | |
| 59 | 60 | </button> |
| 60 | 61 | </form> |
| 61 | 62 | {% endif %} |
| 62 | 63 | {% if !shell.teams.is_empty() %} |
| 63 | -<div class="sheet-wrap" id="equipes" role="dialog" aria-label="Changer d'équipe"> | |
| 64 | - <a class="sheet-backdrop" href="#fermer" aria-label="Fermer"></a> | |
| 64 | +<div class="sheet-wrap" id="equipes" role="dialog" aria-label="{{ shell.tr.change_team }}"> | |
| 65 | + <a class="sheet-backdrop" href="#fermer" aria-label="{{ shell.tr.close }}"></a> | |
| 65 | 66 | <div class="sheet"> |
| 66 | 67 | <div class="sheet-head"> |
| 67 | - <span class="pname">Changer d'équipe</span> | |
| 68 | - <a class="btn btn-ghost close" href="#fermer">Fermer</a> | |
| 68 | + <span class="pname">{{ shell.tr.change_team }}</span> | |
| 69 | + <a class="btn btn-ghost close" href="#fermer">{{ shell.tr.close }}</a> | |
| 69 | 70 | </div> |
| 70 | 71 | <div style="display:grid;gap:var(--space-2)"> |
| 71 | 72 | {% for t in shell.teams %} |
| @@ -91,25 +92,25 @@ | ||
| 91 | 92 | <nav class="tabbar"> |
| 92 | 93 | <a class="tab" href="/temps/nouveau" {% if shell.tab == "add" %}aria-current="page"{% endif %}> |
| 93 | 94 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 8v8M8 12h8"></path></svg> |
| 94 | - <span>Ajouter</span> | |
| 95 | + <span>{{ shell.tr.tab_add }}</span> | |
| 95 | 96 | </a> |
| 96 | 97 | <a class="tab" href="/stats" {% if shell.tab == "stats" %}aria-current="page"{% endif %}> |
| 97 | 98 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 20V10M12 20V4M19 20v-6"></path></svg> |
| 98 | - <span>Stats</span> | |
| 99 | + <span>{{ shell.tr.tab_stats }}</span> | |
| 99 | 100 | </a> |
| 100 | 101 | <a class="tab" href="/taches" {% if shell.tab == "todos" %}aria-current="page"{% endif %}> |
| 101 | 102 | <span class="tab-ico"> |
| 102 | 103 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M11 6h10M11 12h10M11 18h10"></path><path d="M3 6.5l1.6 1.6L7.5 5"></path><path d="M3 12.5l1.6 1.6L7.5 11"></path><path d="M3 18.5l1.6 1.6L7.5 17"></path></svg> |
| 103 | 104 | {% if shell.todo_count > 0 %}<span class="tab-badge">{{ shell.todo_count }}</span>{% endif %} |
| 104 | 105 | </span> |
| 105 | - <span>Tâches</span> | |
| 106 | + <span>{{ shell.tr.tab_todos }}</span> | |
| 106 | 107 | </a> |
| 107 | 108 | <a class="tab" href="/parametres" {% if shell.tab == "settings" %}aria-current="page"{% endif %}> |
| 108 | 109 | <span class="tab-ico"> |
| 109 | 110 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 7 19.4a1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.6 1.6 0 0 0 3 14.9a1.6 1.6 0 0 0-1.5-1H1a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 2.6 8a1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.6 1.6 0 0 0 7 3.7h.1A1.6 1.6 0 0 0 8.1 2.2V2a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1"></path></svg> |
| 110 | 111 | {% if shell.invite_count > 0 %}<span class="tab-badge">{{ shell.invite_count }}</span>{% endif %} |
| 111 | 112 | </span> |
| 112 | - <span>Réglages</span> | |
| 113 | + <span>{{ shell.tr.tab_settings }}</span> | |
| 113 | 114 | </a> |
| 114 | 115 | </nav> |
| 115 | 116 | <script> |
| @@ -1,10 +1,11 @@ | |||
| 1 | {% extends "base.html" %} | 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ shell.title }} · time-time{% endblock %} | 2 | {% block title %}{{ shell.title }} · time-time{% endblock %} |
| 3 | +{% block lang %}{{ shell.lang_code }}{% endblock %} | ||
| 3 | {% block body %} | 4 | {% block body %} |
| 4 | <header class="app-header"> | 5 | <header class="app-header"> |
| 5 | <div class="app-topline"> | 6 | <div class="app-topline"> |
| 6 | {% if !shell.teams.is_empty() %} | 7 | {% if !shell.teams.is_empty() %} |
| 7 | - <a class="team-pill" href="#equipes" aria-label="Changer d'équipe" style="--team-color:{{ shell.team_color }}"> | 8 | + <a class="team-pill" href="#equipes" aria-label="{{ shell.tr.change_team }}" style="--team-color:{{ shell.team_color }}"> |
| 8 | <span class="team-dot" style="background:{{ shell.team_color }}"></span> | 9 | <span class="team-dot" style="background:{{ shell.team_color }}"></span> |
| 9 | {{ shell.team_name }} | 10 | {{ shell.team_name }} |
| 10 | <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"></path></svg> | 11 | <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"></path></svg> |
| @@ -26,7 +27,7 @@ | |||
| 26 | {% if t.running %} | 27 | {% if t.running %} |
| 27 | <form class="timer-slot" method="post" action="/timer/arreter"> | 28 | <form class="timer-slot" method="post" action="/timer/arreter"> |
| 28 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | 29 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 29 | - <button type="submit" class="timer-banner timer-running" aria-label="Arrêter le timer"> | 30 | + <button type="submit" class="timer-banner timer-running" aria-label="{{ shell.tr.stop_timer_aria }}"> |
| 30 | <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13.5" r="7.5"></circle><path d="M12 10v3.5l2.2 1.6"></path><path d="M9.4 2.5h5.2"></path></svg> | 31 | <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13.5" r="7.5"></circle><path d="M12 10v3.5l2.2 1.6"></path><path d="M9.4 2.5h5.2"></path></svg> |
| 31 | <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> | 32 | <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> |
| 32 | <span class="t-note">{{ t.note }}</span> | 33 | <span class="t-note">{{ t.note }}</span> |
| @@ -35,7 +36,7 @@ | |||
| 35 | {% else if t.foreign %} | 36 | {% else if t.foreign %} |
| 36 | <form class="timer-slot" method="post" action="/timer/arreter"> | 37 | <form class="timer-slot" method="post" action="/timer/arreter"> |
| 37 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | 38 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 38 | - <button type="submit" class="timer-banner timer-waiting" aria-label="Enregistrer le temps mesuré (bascule d'équipe)"> | 39 | + <button type="submit" class="timer-banner timer-waiting" aria-label="{{ shell.tr.record_measured_switch_aria }}"> |
| 39 | <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5V12l3 1.8"></path></svg> | 40 | <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5V12l3 1.8"></path></svg> |
| 40 | <span class="t-elapsed">{{ t.label }}</span> | 41 | <span class="t-elapsed">{{ t.label }}</span> |
| 41 | <span class="t-note">{{ t.note }}</span> | 42 | <span class="t-note">{{ t.note }}</span> |
| @@ -43,7 +44,7 @@ | |||
| 43 | </form> | 44 | </form> |
| 44 | {% else %} | 45 | {% else %} |
| 45 | <div class="timer-slot"> | 46 | <div class="timer-slot"> |
| 46 | - <a class="timer-banner timer-waiting" href="/temps/nouveau" aria-label="Enregistrer le temps mesuré"> | 47 | + <a class="timer-banner timer-waiting" href="/temps/nouveau" aria-label="{{ shell.tr.record_measured_aria }}"> |
| 47 | <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5V12l3 1.8"></path></svg> | 48 | <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5V12l3 1.8"></path></svg> |
| 48 | <span class="t-elapsed">{{ t.label }}</span> | 49 | <span class="t-elapsed">{{ t.label }}</span> |
| 49 | </a> | 50 | </a> |
| @@ -53,19 +54,19 @@ | |||
| 53 | <form class="timer-slot" method="post" action="/timer/demarrer"> | 54 | <form class="timer-slot" method="post" action="/timer/demarrer"> |
| 54 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | 55 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 55 | <input type="hidden" name="retour" value="{{ shell.start_return }}"> | 56 | <input type="hidden" name="retour" value="{{ shell.start_return }}"> |
| 56 | - <button type="submit" class="timer-banner timer-start" aria-label="Démarrer le timer"> | 57 | + <button type="submit" class="timer-banner timer-start" aria-label="{{ shell.tr.start_timer_aria }}"> |
| 57 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M10.2 8.6l5.6 3.4-5.6 3.4z"></path></svg> | 58 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M10.2 8.6l5.6 3.4-5.6 3.4z"></path></svg> |
| 58 | - <span>Lancer le minuteur</span> | 59 | + <span>{{ shell.tr.start_timer }}</span> |
| 59 | </button> | 60 | </button> |
| 60 | </form> | 61 | </form> |
| 61 | {% endif %} | 62 | {% endif %} |
| 62 | {% if !shell.teams.is_empty() %} | 63 | {% if !shell.teams.is_empty() %} |
| 63 | -<div class="sheet-wrap" id="equipes" role="dialog" aria-label="Changer d'équipe"> | 64 | +<div class="sheet-wrap" id="equipes" role="dialog" aria-label="{{ shell.tr.change_team }}"> |
| 64 | - <a class="sheet-backdrop" href="#fermer" aria-label="Fermer"></a> | 65 | + <a class="sheet-backdrop" href="#fermer" aria-label="{{ shell.tr.close }}"></a> |
| 65 | <div class="sheet"> | 66 | <div class="sheet"> |
| 66 | <div class="sheet-head"> | 67 | <div class="sheet-head"> |
| 67 | - <span class="pname">Changer d'équipe</span> | 68 | + <span class="pname">{{ shell.tr.change_team }}</span> |
| 68 | - <a class="btn btn-ghost close" href="#fermer">Fermer</a> | 69 | + <a class="btn btn-ghost close" href="#fermer">{{ shell.tr.close }}</a> |
| 69 | </div> | 70 | </div> |
| 70 | <div style="display:grid;gap:var(--space-2)"> | 71 | <div style="display:grid;gap:var(--space-2)"> |
| 71 | {% for t in shell.teams %} | 72 | {% for t in shell.teams %} |
| @@ -91,25 +92,25 @@ | |||
| 91 | <nav class="tabbar"> | 92 | <nav class="tabbar"> |
| 92 | <a class="tab" href="/temps/nouveau" {% if shell.tab == "add" %}aria-current="page"{% endif %}> | 93 | <a class="tab" href="/temps/nouveau" {% if shell.tab == "add" %}aria-current="page"{% endif %}> |
| 93 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 8v8M8 12h8"></path></svg> | 94 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 8v8M8 12h8"></path></svg> |
| 94 | - <span>Ajouter</span> | 95 | + <span>{{ shell.tr.tab_add }}</span> |
| 95 | </a> | 96 | </a> |
| 96 | <a class="tab" href="/stats" {% if shell.tab == "stats" %}aria-current="page"{% endif %}> | 97 | <a class="tab" href="/stats" {% if shell.tab == "stats" %}aria-current="page"{% endif %}> |
| 97 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 20V10M12 20V4M19 20v-6"></path></svg> | 98 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 20V10M12 20V4M19 20v-6"></path></svg> |
| 98 | - <span>Stats</span> | 99 | + <span>{{ shell.tr.tab_stats }}</span> |
| 99 | </a> | 100 | </a> |
| 100 | <a class="tab" href="/taches" {% if shell.tab == "todos" %}aria-current="page"{% endif %}> | 101 | <a class="tab" href="/taches" {% if shell.tab == "todos" %}aria-current="page"{% endif %}> |
| 101 | <span class="tab-ico"> | 102 | <span class="tab-ico"> |
| 102 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M11 6h10M11 12h10M11 18h10"></path><path d="M3 6.5l1.6 1.6L7.5 5"></path><path d="M3 12.5l1.6 1.6L7.5 11"></path><path d="M3 18.5l1.6 1.6L7.5 17"></path></svg> | 103 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M11 6h10M11 12h10M11 18h10"></path><path d="M3 6.5l1.6 1.6L7.5 5"></path><path d="M3 12.5l1.6 1.6L7.5 11"></path><path d="M3 18.5l1.6 1.6L7.5 17"></path></svg> |
| 103 | {% if shell.todo_count > 0 %}<span class="tab-badge">{{ shell.todo_count }}</span>{% endif %} | 104 | {% if shell.todo_count > 0 %}<span class="tab-badge">{{ shell.todo_count }}</span>{% endif %} |
| 104 | </span> | 105 | </span> |
| 105 | - <span>Tâches</span> | 106 | + <span>{{ shell.tr.tab_todos }}</span> |
| 106 | </a> | 107 | </a> |
| 107 | <a class="tab" href="/parametres" {% if shell.tab == "settings" %}aria-current="page"{% endif %}> | 108 | <a class="tab" href="/parametres" {% if shell.tab == "settings" %}aria-current="page"{% endif %}> |
| 108 | <span class="tab-ico"> | 109 | <span class="tab-ico"> |
| 109 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 7 19.4a1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.6 1.6 0 0 0 3 14.9a1.6 1.6 0 0 0-1.5-1H1a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 2.6 8a1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.6 1.6 0 0 0 7 3.7h.1A1.6 1.6 0 0 0 8.1 2.2V2a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1"></path></svg> | 110 | <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 7 19.4a1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.6 1.6 0 0 0 3 14.9a1.6 1.6 0 0 0-1.5-1H1a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 2.6 8a1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.6 1.6 0 0 0 7 3.7h.1A1.6 1.6 0 0 0 8.1 2.2V2a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1"></path></svg> |
| 110 | {% if shell.invite_count > 0 %}<span class="tab-badge">{{ shell.invite_count }}</span>{% endif %} | 111 | {% if shell.invite_count > 0 %}<span class="tab-badge">{{ shell.invite_count }}</span>{% endif %} |
| 111 | </span> | 112 | </span> |
| 112 | - <span>Réglages</span> | 113 | + <span>{{ shell.tr.tab_settings }}</span> |
| 113 | </a> | 114 | </a> |
| 114 | </nav> | 115 | </nav> |
| 115 | <script> | 116 | <script> |
modified
templates/base.html +1 -1 | @@ -1,5 +1,5 @@ | ||
| 1 | 1 | <!doctype html> |
| 2 | -<html lang="fr"> | |
| 2 | +<html lang="{% block lang %}fr{% endblock %}"> | |
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="utf-8"> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> |
| @@ -1,5 +1,5 @@ | |||
| 1 | <!doctype html> | 1 | <!doctype html> |
| 2 | -<html lang="fr"> | 2 | +<html lang="{% block lang %}fr{% endblock %}"> |
| 3 | <head> | 3 | <head> |
| 4 | <meta charset="utf-8"> | 4 | <meta charset="utf-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | 5 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> |
modified
templates/categories.html +9 -9 | @@ -1,34 +1,34 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 2 | {% block content %} |
| 3 | -<p class="step-hint">Les catégories appartiennent à l'équipe {{ shell.team_name }} et sont modifiables par tous ses membres. Archiver une catégorie la retire des listes ; les temps déjà saisis restent dans les stats.</p> | |
| 3 | +<p class="step-hint">{{ hint }}</p> | |
| 4 | 4 | <form method="post" action="/parametres/categories" class="category-add-row"> |
| 5 | 5 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 6 | - <input class="input" name="name" placeholder="Nouvelle catégorie" required aria-label="Nouvelle catégorie"> | |
| 7 | - <button type="submit" class="btn btn-primary">Ajouter</button> | |
| 6 | + <input class="input" name="name" placeholder="{{ shell.tr.new_category_ph }}" required aria-label="{{ shell.tr.new_category_ph }}"> | |
| 7 | + <button type="submit" class="btn btn-primary">{{ shell.tr.add }}</button> | |
| 8 | 8 | </form> |
| 9 | 9 | {% for t in categories %} |
| 10 | 10 | <div class="category-row {% if t.archived %}archived{% endif %}"> |
| 11 | 11 | <span class="dot dot-12" style="background:{{ t.color }}"></span> |
| 12 | 12 | <form method="post" action="{{ t.rename_action }}" class="rename"> |
| 13 | 13 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 14 | - <input class="input" name="name" value="{{ t.name }}" required aria-label="Renommer la catégorie (Entrée pour valider)"> | |
| 14 | + <input class="input" name="name" value="{{ t.name }}" required aria-label="{{ shell.tr.rename_cat_aria }}"> | |
| 15 | 15 | {# tabindex=-1 : la soumission clavier passe par Entrée dans l'input ; |
| 16 | 16 | sans ça, ce bouton invisible est un stop de tabulation sans focus visible. #} |
| 17 | - <button type="submit" class="visually-hidden" tabindex="-1">Renommer</button> | |
| 17 | + <button type="submit" class="visually-hidden" tabindex="-1">{{ shell.tr.rename }}</button> | |
| 18 | 18 | </form> |
| 19 | 19 | <form method="post" action="{{ t.toggle_action }}"> |
| 20 | 20 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 21 | 21 | {% if t.archived %} |
| 22 | 22 | <input type="hidden" name="restaure" value="1"> |
| 23 | - <button type="submit" class="btn btn-ghost" style="font-size:12px">Restaurer</button> | |
| 23 | + <button type="submit" class="btn btn-ghost" style="font-size:12px">{{ shell.tr.restore }}</button> | |
| 24 | 24 | {% else %} |
| 25 | - <button type="submit" class="btn btn-secondary btn-icon-sm" aria-label="Archiver"> | |
| 25 | + <button type="submit" class="btn btn-secondary btn-icon-sm" aria-label="{{ shell.tr.archive_aria }}"> | |
| 26 | 26 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="4" rx="1"></rect><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8"></path><path d="M10 12h4"></path></svg> |
| 27 | 27 | </button> |
| 28 | 28 | {% endif %} |
| 29 | 29 | </form> |
| 30 | 30 | </div> |
| 31 | 31 | {% endfor %} |
| 32 | -<p class="text-muted" style="font-size:12px;margin-top:var(--space-3)">Renommer : modifie le nom puis appuie sur Entrée.</p> | |
| 33 | -<a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/temps/nouveau">← Retour à l'ajout</a> | |
| 32 | +<p class="text-muted" style="font-size:12px;margin-top:var(--space-3)">{{ shell.tr.rename_help }}</p> | |
| 33 | +<a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/temps/nouveau">{{ shell.tr.back_add }}</a> | |
| 34 | 34 | {% endblock %} |
| @@ -1,34 +1,34 @@ | |||
| 1 | {% extends "app.html" %} | 1 | {% extends "app.html" %} |
| 2 | {% block content %} | 2 | {% block content %} |
| 3 | -<p class="step-hint">Les catégories appartiennent à l'équipe {{ shell.team_name }} et sont modifiables par tous ses membres. Archiver une catégorie la retire des listes ; les temps déjà saisis restent dans les stats.</p> | 3 | +<p class="step-hint">{{ hint }}</p> |
| 4 | <form method="post" action="/parametres/categories" class="category-add-row"> | 4 | <form method="post" action="/parametres/categories" class="category-add-row"> |
| 5 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 5 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 6 | - <input class="input" name="name" placeholder="Nouvelle catégorie" required aria-label="Nouvelle catégorie"> | 6 | + <input class="input" name="name" placeholder="{{ shell.tr.new_category_ph }}" required aria-label="{{ shell.tr.new_category_ph }}"> |
| 7 | - <button type="submit" class="btn btn-primary">Ajouter</button> | 7 | + <button type="submit" class="btn btn-primary">{{ shell.tr.add }}</button> |
| 8 | </form> | 8 | </form> |
| 9 | {% for t in categories %} | 9 | {% for t in categories %} |
| 10 | <div class="category-row {% if t.archived %}archived{% endif %}"> | 10 | <div class="category-row {% if t.archived %}archived{% endif %}"> |
| 11 | <span class="dot dot-12" style="background:{{ t.color }}"></span> | 11 | <span class="dot dot-12" style="background:{{ t.color }}"></span> |
| 12 | <form method="post" action="{{ t.rename_action }}" class="rename"> | 12 | <form method="post" action="{{ t.rename_action }}" class="rename"> |
| 13 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 13 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 14 | - <input class="input" name="name" value="{{ t.name }}" required aria-label="Renommer la catégorie (Entrée pour valider)"> | 14 | + <input class="input" name="name" value="{{ t.name }}" required aria-label="{{ shell.tr.rename_cat_aria }}"> |
| 15 | {# tabindex=-1 : la soumission clavier passe par Entrée dans l'input ; | 15 | {# tabindex=-1 : la soumission clavier passe par Entrée dans l'input ; |
| 16 | sans ça, ce bouton invisible est un stop de tabulation sans focus visible. #} | 16 | sans ça, ce bouton invisible est un stop de tabulation sans focus visible. #} |
| 17 | - <button type="submit" class="visually-hidden" tabindex="-1">Renommer</button> | 17 | + <button type="submit" class="visually-hidden" tabindex="-1">{{ shell.tr.rename }}</button> |
| 18 | </form> | 18 | </form> |
| 19 | <form method="post" action="{{ t.toggle_action }}"> | 19 | <form method="post" action="{{ t.toggle_action }}"> |
| 20 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 20 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 21 | {% if t.archived %} | 21 | {% if t.archived %} |
| 22 | <input type="hidden" name="restaure" value="1"> | 22 | <input type="hidden" name="restaure" value="1"> |
| 23 | - <button type="submit" class="btn btn-ghost" style="font-size:12px">Restaurer</button> | 23 | + <button type="submit" class="btn btn-ghost" style="font-size:12px">{{ shell.tr.restore }}</button> |
| 24 | {% else %} | 24 | {% else %} |
| 25 | - <button type="submit" class="btn btn-secondary btn-icon-sm" aria-label="Archiver"> | 25 | + <button type="submit" class="btn btn-secondary btn-icon-sm" aria-label="{{ shell.tr.archive_aria }}"> |
| 26 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="4" rx="1"></rect><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8"></path><path d="M10 12h4"></path></svg> | 26 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="4" rx="1"></rect><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8"></path><path d="M10 12h4"></path></svg> |
| 27 | </button> | 27 | </button> |
| 28 | {% endif %} | 28 | {% endif %} |
| 29 | </form> | 29 | </form> |
| 30 | </div> | 30 | </div> |
| 31 | {% endfor %} | 31 | {% endfor %} |
| 32 | -<p class="text-muted" style="font-size:12px;margin-top:var(--space-3)">Renommer : modifie le nom puis appuie sur Entrée.</p> | 32 | +<p class="text-muted" style="font-size:12px;margin-top:var(--space-3)">{{ shell.tr.rename_help }}</p> |
| 33 | -<a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/temps/nouveau">← Retour à l'ajout</a> | 33 | +<a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/temps/nouveau">{{ shell.tr.back_add }}</a> |
| 34 | {% endblock %} | 34 | {% endblock %} |
modified
templates/edit.html +6 -6 | @@ -1,10 +1,10 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 2 | {% block content %} |
| 3 | -<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0" href="/temps">← Mes temps</a> | |
| 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0" href="/temps">{{ shell.tr.back_times }}</a> | |
| 4 | 4 | <form method="post" action="{{ action }}"> |
| 5 | 5 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 6 | 6 | <div class="field" style="margin-bottom:var(--space-3)"> |
| 7 | - <label for="categorie">Catégorie</label> | |
| 7 | + <label for="categorie">{{ shell.tr.category_label }}</label> | |
| 8 | 8 | <select class="input" id="categorie" name="categorie" required style="min-height:44px"> |
| 9 | 9 | {% for t in categories %} |
| 10 | 10 | <option value="{{ t.id }}"{% if t.selected %} selected{% endif %}>{{ t.name }}</option> |
| @@ -13,19 +13,19 @@ | ||
| 13 | 13 | </div> |
| 14 | 14 | <div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-4)"> |
| 15 | 15 | <div class="field" style="flex:1"> |
| 16 | - <label for="minutes">Durée (min)</label> | |
| 16 | + <label for="minutes">{{ shell.tr.duration_min_label }}</label> | |
| 17 | 17 | <input class="input" id="minutes" name="minutes" type="number" inputmode="numeric" |
| 18 | 18 | min="1" required value="{{ minutes }}" style="min-height:44px"> |
| 19 | 19 | </div> |
| 20 | 20 | <div class="field" style="flex:1.4"> |
| 21 | - <label for="date">Date</label> | |
| 21 | + <label for="date">{{ shell.tr.date_label }}</label> | |
| 22 | 22 | <input class="input" id="date" name="date" type="date" value="{{ date }}" required style="min-height:44px"> |
| 23 | 23 | </div> |
| 24 | 24 | </div> |
| 25 | - <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">Enregistrer</button> | |
| 25 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.save }}</button> | |
| 26 | 26 | </form> |
| 27 | 27 | <form method="post" action="{{ delete_action }}" style="margin-top:var(--space-3)"> |
| 28 | 28 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 29 | - <button type="submit" class="btn btn-secondary btn-block" style="color:var(--color-accent-700)">Supprimer</button> | |
| 29 | + <button type="submit" class="btn btn-secondary btn-block" style="color:var(--color-accent-700)">{{ shell.tr.delete }}</button> | |
| 30 | 30 | </form> |
| 31 | 31 | {% endblock %} |
| @@ -1,10 +1,10 @@ | |||
| 1 | {% extends "app.html" %} | 1 | {% extends "app.html" %} |
| 2 | {% block content %} | 2 | {% block content %} |
| 3 | -<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0" href="/temps">← Mes temps</a> | 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0" href="/temps">{{ shell.tr.back_times }}</a> |
| 4 | <form method="post" action="{{ action }}"> | 4 | <form method="post" action="{{ action }}"> |
| 5 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 5 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 6 | <div class="field" style="margin-bottom:var(--space-3)"> | 6 | <div class="field" style="margin-bottom:var(--space-3)"> |
| 7 | - <label for="categorie">Catégorie</label> | 7 | + <label for="categorie">{{ shell.tr.category_label }}</label> |
| 8 | <select class="input" id="categorie" name="categorie" required style="min-height:44px"> | 8 | <select class="input" id="categorie" name="categorie" required style="min-height:44px"> |
| 9 | {% for t in categories %} | 9 | {% for t in categories %} |
| 10 | <option value="{{ t.id }}"{% if t.selected %} selected{% endif %}>{{ t.name }}</option> | 10 | <option value="{{ t.id }}"{% if t.selected %} selected{% endif %}>{{ t.name }}</option> |
| @@ -13,19 +13,19 @@ | |||
| 13 | </div> | 13 | </div> |
| 14 | <div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-4)"> | 14 | <div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-4)"> |
| 15 | <div class="field" style="flex:1"> | 15 | <div class="field" style="flex:1"> |
| 16 | - <label for="minutes">Durée (min)</label> | 16 | + <label for="minutes">{{ shell.tr.duration_min_label }}</label> |
| 17 | <input class="input" id="minutes" name="minutes" type="number" inputmode="numeric" | 17 | <input class="input" id="minutes" name="minutes" type="number" inputmode="numeric" |
| 18 | min="1" required value="{{ minutes }}" style="min-height:44px"> | 18 | min="1" required value="{{ minutes }}" style="min-height:44px"> |
| 19 | </div> | 19 | </div> |
| 20 | <div class="field" style="flex:1.4"> | 20 | <div class="field" style="flex:1.4"> |
| 21 | - <label for="date">Date</label> | 21 | + <label for="date">{{ shell.tr.date_label }}</label> |
| 22 | <input class="input" id="date" name="date" type="date" value="{{ date }}" required style="min-height:44px"> | 22 | <input class="input" id="date" name="date" type="date" value="{{ date }}" required style="min-height:44px"> |
| 23 | </div> | 23 | </div> |
| 24 | </div> | 24 | </div> |
| 25 | - <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">Enregistrer</button> | 25 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.save }}</button> |
| 26 | </form> | 26 | </form> |
| 27 | <form method="post" action="{{ delete_action }}" style="margin-top:var(--space-3)"> | 27 | <form method="post" action="{{ delete_action }}" style="margin-top:var(--space-3)"> |
| 28 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 28 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 29 | - <button type="submit" class="btn btn-secondary btn-block" style="color:var(--color-accent-700)">Supprimer</button> | 29 | + <button type="submit" class="btn btn-secondary btn-block" style="color:var(--color-accent-700)">{{ shell.tr.delete }}</button> |
| 30 | </form> | 30 | </form> |
| 31 | {% endblock %} | 31 | {% endblock %} |
modified
templates/login.html +7 -6 | @@ -1,24 +1,25 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | -{% block title %}Connexion · time-time{% endblock %} | |
| 2 | +{% block title %}{{ tr.login_title }} · time-time{% endblock %} | |
| 3 | +{% block lang %}{{ lang_code }}{% endblock %} | |
| 3 | 4 | {% block body %} |
| 4 | 5 | <main class="login"> |
| 5 | 6 | <div class="login-icon"> |
| 6 | 7 | <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#f5ead8" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg> |
| 7 | 8 | </div> |
| 8 | 9 | <h1>time-time</h1> |
| 9 | - <p class="tagline">Le temps passé sur chaque catégorie, partagé avec ton équipe.</p> | |
| 10 | + <p class="tagline">{{ tr.tagline }}</p> | |
| 10 | 11 | <form method="post" action="/login"> |
| 11 | 12 | <div class="field" style="margin-bottom:var(--space-3)"> |
| 12 | - <label for="username">Identifiant</label> | |
| 13 | + <label for="username">{{ tr.username_label }}</label> | |
| 13 | 14 | <input class="input" id="username" name="username" autocomplete="username" |
| 14 | 15 | autocapitalize="none" autocorrect="off" required autofocus> |
| 15 | 16 | </div> |
| 16 | 17 | <div class="field" style="margin-bottom:var(--space-4)"> |
| 17 | - <label for="password">Mot de passe</label> | |
| 18 | + <label for="password">{{ tr.password_label }}</label> | |
| 18 | 19 | <input class="input" id="password" name="password" type="password" autocomplete="current-password" required> |
| 19 | 20 | </div> |
| 20 | - {% if error %}<p class="form-error">Identifiants incorrects.</p>{% endif %} | |
| 21 | - <button type="submit" class="btn btn-primary btn-block" style="min-height:48px;font-size:16px">Se connecter</button> | |
| 21 | + {% if error %}<p class="form-error">{{ tr.bad_credentials }}</p>{% endif %} | |
| 22 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:48px;font-size:16px">{{ tr.sign_in }}</button> | |
| 22 | 23 | </form> |
| 23 | 24 | </main> |
| 24 | 25 | {% endblock %} |
| @@ -1,24 +1,25 @@ | |||
| 1 | {% extends "base.html" %} | 1 | {% extends "base.html" %} |
| 2 | -{% block title %}Connexion · time-time{% endblock %} | 2 | +{% block title %}{{ tr.login_title }} · time-time{% endblock %} |
| 3 | +{% block lang %}{{ lang_code }}{% endblock %} | ||
| 3 | {% block body %} | 4 | {% block body %} |
| 4 | <main class="login"> | 5 | <main class="login"> |
| 5 | <div class="login-icon"> | 6 | <div class="login-icon"> |
| 6 | <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#f5ead8" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg> | 7 | <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#f5ead8" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg> |
| 7 | </div> | 8 | </div> |
| 8 | <h1>time-time</h1> | 9 | <h1>time-time</h1> |
| 9 | - <p class="tagline">Le temps passé sur chaque catégorie, partagé avec ton équipe.</p> | 10 | + <p class="tagline">{{ tr.tagline }}</p> |
| 10 | <form method="post" action="/login"> | 11 | <form method="post" action="/login"> |
| 11 | <div class="field" style="margin-bottom:var(--space-3)"> | 12 | <div class="field" style="margin-bottom:var(--space-3)"> |
| 12 | - <label for="username">Identifiant</label> | 13 | + <label for="username">{{ tr.username_label }}</label> |
| 13 | <input class="input" id="username" name="username" autocomplete="username" | 14 | <input class="input" id="username" name="username" autocomplete="username" |
| 14 | autocapitalize="none" autocorrect="off" required autofocus> | 15 | autocapitalize="none" autocorrect="off" required autofocus> |
| 15 | </div> | 16 | </div> |
| 16 | <div class="field" style="margin-bottom:var(--space-4)"> | 17 | <div class="field" style="margin-bottom:var(--space-4)"> |
| 17 | - <label for="password">Mot de passe</label> | 18 | + <label for="password">{{ tr.password_label }}</label> |
| 18 | <input class="input" id="password" name="password" type="password" autocomplete="current-password" required> | 19 | <input class="input" id="password" name="password" type="password" autocomplete="current-password" required> |
| 19 | </div> | 20 | </div> |
| 20 | - {% if error %}<p class="form-error">Identifiants incorrects.</p>{% endif %} | 21 | + {% if error %}<p class="form-error">{{ tr.bad_credentials }}</p>{% endif %} |
| 21 | - <button type="submit" class="btn btn-primary btn-block" style="min-height:48px;font-size:16px">Se connecter</button> | 22 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:48px;font-size:16px">{{ tr.sign_in }}</button> |
| 22 | </form> | 23 | </form> |
| 23 | </main> | 24 | </main> |
| 24 | {% endblock %} | 25 | {% endblock %} |
modified
templates/settings.html +42 -29 | @@ -4,16 +4,16 @@ | ||
| 4 | 4 | <div style="margin-bottom:var(--space-6)"> |
| 5 | 5 | {% for inv in invites %} |
| 6 | 6 | <div class="invite-card"> |
| 7 | - <div class="invite-kicker"><span class="idot"></span>Invitation</div> | |
| 7 | + <div class="invite-kicker"><span class="idot"></span>{{ shell.tr.invitation_kicker }}</div> | |
| 8 | 8 | <p>{{ inv.text }}</p> |
| 9 | 9 | <div style="display:flex;gap:var(--space-2)"> |
| 10 | 10 | <form method="post" action="{{ inv.accept_action }}" style="flex:1;display:flex"> |
| 11 | 11 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 12 | - <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px">Accepter</button> | |
| 12 | + <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px">{{ shell.tr.accept }}</button> | |
| 13 | 13 | </form> |
| 14 | 14 | <form method="post" action="{{ inv.refuse_action }}" style="display:flex"> |
| 15 | 15 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 16 | - <button type="submit" class="btn btn-secondary" style="min-height:44px">Refuser</button> | |
| 16 | + <button type="submit" class="btn btn-secondary" style="min-height:44px">{{ shell.tr.refuse }}</button> | |
| 17 | 17 | </form> |
| 18 | 18 | </div> |
| 19 | 19 | </div> |
| @@ -22,15 +22,15 @@ | ||
| 22 | 22 | {% endif %} |
| 23 | 23 | |
| 24 | 24 | <div class="card" style="margin-bottom:var(--space-3)"> |
| 25 | - <div class="card-kicker">Compte</div> | |
| 25 | + <div class="card-kicker">{{ shell.tr.account_kicker }}</div> | |
| 26 | 26 | <div style="font-size:19px;font-family:var(--font-heading)">{{ shell.username }}</div> |
| 27 | 27 | <p class="card-body">{{ account_line }}</p> |
| 28 | 28 | </div> |
| 29 | 29 | |
| 30 | 30 | <a class="card times-link" href="/temps" style="margin-bottom:var(--space-6)"> |
| 31 | 31 | <span style="flex:1"> |
| 32 | - <span style="display:block;font-family:var(--font-heading);font-size:17px">Mes saisies</span> | |
| 33 | - <span style="display:block;font-size:13px;opacity:.7">Consulter et corriger les temps que j'ai enregistrés.</span> | |
| 32 | + <span style="display:block;font-family:var(--font-heading);font-size:17px">{{ shell.tr.my_entries }}</span> | |
| 33 | + <span style="display:block;font-size:13px;opacity:.7">{{ shell.tr.my_entries_desc }}</span> | |
| 34 | 34 | </span> |
| 35 | 35 | <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> |
| 36 | 36 | </a> |
| @@ -38,16 +38,16 @@ | ||
| 38 | 38 | <form method="post" action="/equipes/renommer"> |
| 39 | 39 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 40 | 40 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 41 | - <label for="eq-nom">Nom de l'équipe</label> | |
| 41 | + <label for="eq-nom">{{ shell.tr.team_name_label }}</label> | |
| 42 | 42 | <div style="display:flex;gap:var(--space-2)"> |
| 43 | 43 | <input class="input" id="eq-nom" name="nom" value="{{ shell.team_name }}" required style="flex:1;min-height:44px"> |
| 44 | - <button type="submit" class="btn btn-primary" style="min-height:44px">Renommer</button> | |
| 44 | + <button type="submit" class="btn btn-primary" style="min-height:44px">{{ shell.tr.rename }}</button> | |
| 45 | 45 | </div> |
| 46 | 46 | </div> |
| 47 | 47 | </form> |
| 48 | -<p style="font-size:12px;opacity:.7;margin:0 0 var(--space-6)">Le nouveau nom est visible par tous les membres.</p> | |
| 48 | +<p style="font-size:12px;opacity:.7;margin:0 0 var(--space-6)">{{ shell.tr.rename_team_note }}</p> | |
| 49 | 49 | |
| 50 | -<h2 style="font-size:16px;margin:0 0 var(--space-2)">Membres de {{ shell.team_name }}</h2> | |
| 50 | +<h2 style="font-size:16px;margin:0 0 var(--space-2)">{{ members_title }}</h2> | |
| 51 | 51 | <div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-4)"> |
| 52 | 52 | {% for m in members %}<span class="tag tag-neutral">{{ m }}</span>{% endfor %} |
| 53 | 53 | </div> |
| @@ -55,18 +55,18 @@ | ||
| 55 | 55 | <form method="post" action="/equipes/inviter"> |
| 56 | 56 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 57 | 57 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 58 | - <label for="invite-nom">Inviter un utilisateur</label> | |
| 58 | + <label for="invite-nom">{{ shell.tr.invite_user_label }}</label> | |
| 59 | 59 | <div style="display:flex;gap:var(--space-2)"> |
| 60 | - <input class="input" id="invite-nom" name="invite" placeholder="identifiant" autocapitalize="none" autocorrect="off" autocomplete="off" data-1p-ignore required style="flex:1;min-height:44px"> | |
| 61 | - <button type="submit" class="btn btn-primary" style="min-height:44px">Inviter</button> | |
| 60 | + <input class="input" id="invite-nom" name="invite" placeholder="{{ shell.tr.username_ph }}" autocapitalize="none" autocorrect="off" autocomplete="off" data-1p-ignore required style="flex:1;min-height:44px"> | |
| 61 | + <button type="submit" class="btn btn-primary" style="min-height:44px">{{ shell.tr.invite }}</button> | |
| 62 | 62 | </div> |
| 63 | 63 | </div> |
| 64 | 64 | </form> |
| 65 | 65 | {% if let Some(e) = invite_error %}<p class="form-error" style="margin:0 0 var(--space-2)">{{ e }}</p>{% endif %} |
| 66 | -<p style="font-size:12px;opacity:.7;margin:0">L'utilisateur doit déjà avoir un compte. Il reçoit une invitation à accepter de son côté.</p> | |
| 66 | +<p style="font-size:12px;opacity:.7;margin:0">{{ shell.tr.invite_note }}</p> | |
| 67 | 67 | |
| 68 | 68 | {% if !sent.is_empty() %} |
| 69 | -<h2 style="font-size:16px;margin:var(--space-6) 0 var(--space-2)">Invitations en attente</h2> | |
| 69 | +<h2 style="font-size:16px;margin:var(--space-6) 0 var(--space-2)">{{ shell.tr.pending_invites }}</h2> | |
| 70 | 70 | {% for si in sent %} |
| 71 | 71 | <div class="sent-row"> |
| 72 | 72 | <div style="flex:1;min-width:0"> |
| @@ -75,54 +75,67 @@ | ||
| 75 | 75 | </div> |
| 76 | 76 | <form method="post" action="{{ si.cancel_action }}" style="margin:0"> |
| 77 | 77 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 78 | - <button type="submit" class="btn btn-ghost" style="font-size:12px">Annuler</button> | |
| 78 | + <button type="submit" class="btn btn-ghost" style="font-size:12px">{{ shell.tr.cancel }}</button> | |
| 79 | 79 | </form> |
| 80 | 80 | </div> |
| 81 | 81 | {% endfor %} |
| 82 | 82 | {% endif %} |
| 83 | 83 | |
| 84 | -<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-2)">Créer une équipe</h2> | |
| 84 | +<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-2)">{{ shell.tr.create_team }}</h2> | |
| 85 | 85 | <form method="post" action="/equipes"> |
| 86 | 86 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 87 | 87 | <div style="display:flex;gap:var(--space-2)"> |
| 88 | - <input class="input" name="nom" placeholder="Nom de l'équipe" required aria-label="Nom de la nouvelle équipe" style="flex:1;min-height:44px"> | |
| 89 | - <button type="submit" class="btn btn-primary" style="min-height:44px">Créer</button> | |
| 88 | + <input class="input" name="nom" placeholder="{{ shell.tr.team_name_label }}" required aria-label="{{ shell.tr.new_team_aria }}" style="flex:1;min-height:44px"> | |
| 89 | + <button type="submit" class="btn btn-primary" style="min-height:44px">{{ shell.tr.create }}</button> | |
| 90 | 90 | </div> |
| 91 | 91 | </form> |
| 92 | -<p style="font-size:12px;opacity:.7;margin:var(--space-2) 0 0">Tu bascules dessus aussitôt — invite ensuite qui tu veux.</p> | |
| 92 | +<p style="font-size:12px;opacity:.7;margin:var(--space-2) 0 0">{{ shell.tr.create_team_note }}</p> | |
| 93 | 93 | |
| 94 | 94 | {% if can_leave %} |
| 95 | 95 | <form method="post" action="/equipes/quitter"> |
| 96 | 96 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 97 | 97 | <button type="submit" class="btn btn-block" |
| 98 | - style="min-height:46px;margin-top:var(--space-6);color:var(--color-accent-700);border:1px solid var(--color-accent-300)">Quitter « {{ shell.team_name }} »</button> | |
| 98 | + style="min-height:46px;margin-top:var(--space-6);color:var(--color-accent-700);border:1px solid var(--color-accent-300)">{{ leave_label }}</button> | |
| 99 | 99 | </form> |
| 100 | -<p style="font-size:11px;opacity:.7;margin-top:var(--space-2);text-align:center">Tes temps déjà saisis restent dans les stats de l'équipe.</p> | |
| 100 | +<p style="font-size:11px;opacity:.7;margin-top:var(--space-2);text-align:center">{{ shell.tr.leave_note }}</p> | |
| 101 | 101 | {% endif %} |
| 102 | 102 | |
| 103 | -<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">Session</h2> | |
| 103 | +<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-2)">{{ shell.tr.language }}</h2> | |
| 104 | +<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-2)"> | |
| 105 | + {% for l in langs %} | |
| 106 | + <form method="post" action="/parametres/langue" style="margin:0"> | |
| 107 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | |
| 108 | + <input type="hidden" name="langue" value="{{ l.code }}"> | |
| 109 | + <button type="submit" class="lang-pill{% if l.current %} current{% endif %}" {% if l.current %}aria-current="true"{% endif %} lang="{{ l.code }}"> | |
| 110 | + {{ l.name }}{% if l.current %} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"></path></svg>{% endif %} | |
| 111 | + </button> | |
| 112 | + </form> | |
| 113 | + {% endfor %} | |
| 114 | +</div> | |
| 115 | + | |
| 116 | +<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2> | |
| 104 | 117 | <form method="post" action="/parametres/mot-de-passe"> |
| 105 | 118 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 106 | 119 | <div class="field" style="margin-bottom:var(--space-3)"> |
| 107 | - <label for="ancien">Mot de passe actuel</label> | |
| 120 | + <label for="ancien">{{ shell.tr.current_password }}</label> | |
| 108 | 121 | <input class="input" id="ancien" name="ancien" type="password" autocomplete="current-password" required style="min-height:44px"> |
| 109 | 122 | </div> |
| 110 | 123 | <div class="field" style="margin-bottom:var(--space-3)"> |
| 111 | - <label for="nouveau">Nouveau mot de passe</label> | |
| 124 | + <label for="nouveau">{{ shell.tr.new_password }}</label> | |
| 112 | 125 | <input class="input" id="nouveau" name="nouveau" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px"> |
| 113 | 126 | </div> |
| 114 | 127 | <div class="field" style="margin-bottom:var(--space-4)"> |
| 115 | - <label for="confirmation">Confirmation</label> | |
| 128 | + <label for="confirmation">{{ shell.tr.confirmation }}</label> | |
| 116 | 129 | <input class="input" id="confirmation" name="confirmation" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px"> |
| 117 | 130 | </div> |
| 118 | 131 | {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %} |
| 119 | - <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">Changer le mot de passe</button> | |
| 132 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.change_password }}</button> | |
| 120 | 133 | </form> |
| 121 | 134 | |
| 122 | 135 | <form method="post" action="/logout"> |
| 123 | 136 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 124 | 137 | <button type="submit" class="btn btn-block" |
| 125 | - style="min-height:46px;margin-top:var(--space-4);color:var(--color-accent-700);border:1px solid var(--color-accent-300)">Se déconnecter</button> | |
| 138 | + style="min-height:46px;margin-top:var(--space-4);color:var(--color-accent-700);border:1px solid var(--color-accent-300)">{{ shell.tr.sign_out }}</button> | |
| 126 | 139 | </form> |
| 127 | -<p class="text-muted" style="font-size:11px;margin-top:var(--space-3);text-align:center">Le biscuit de session sera révoqué.</p> | |
| 140 | +<p class="text-muted" style="font-size:11px;margin-top:var(--space-3);text-align:center">{{ shell.tr.signout_note }}</p> | |
| 128 | 141 | {% endblock %} |
| @@ -4,16 +4,16 @@ | |||
| 4 | <div style="margin-bottom:var(--space-6)"> | 4 | <div style="margin-bottom:var(--space-6)"> |
| 5 | {% for inv in invites %} | 5 | {% for inv in invites %} |
| 6 | <div class="invite-card"> | 6 | <div class="invite-card"> |
| 7 | - <div class="invite-kicker"><span class="idot"></span>Invitation</div> | 7 | + <div class="invite-kicker"><span class="idot"></span>{{ shell.tr.invitation_kicker }}</div> |
| 8 | <p>{{ inv.text }}</p> | 8 | <p>{{ inv.text }}</p> |
| 9 | <div style="display:flex;gap:var(--space-2)"> | 9 | <div style="display:flex;gap:var(--space-2)"> |
| 10 | <form method="post" action="{{ inv.accept_action }}" style="flex:1;display:flex"> | 10 | <form method="post" action="{{ inv.accept_action }}" style="flex:1;display:flex"> |
| 11 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 11 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 12 | - <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px">Accepter</button> | 12 | + <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px">{{ shell.tr.accept }}</button> |
| 13 | </form> | 13 | </form> |
| 14 | <form method="post" action="{{ inv.refuse_action }}" style="display:flex"> | 14 | <form method="post" action="{{ inv.refuse_action }}" style="display:flex"> |
| 15 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 15 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 16 | - <button type="submit" class="btn btn-secondary" style="min-height:44px">Refuser</button> | 16 | + <button type="submit" class="btn btn-secondary" style="min-height:44px">{{ shell.tr.refuse }}</button> |
| 17 | </form> | 17 | </form> |
| 18 | </div> | 18 | </div> |
| 19 | </div> | 19 | </div> |
| @@ -22,15 +22,15 @@ | |||
| 22 | {% endif %} | 22 | {% endif %} |
| 23 | 23 | ||
| 24 | <div class="card" style="margin-bottom:var(--space-3)"> | 24 | <div class="card" style="margin-bottom:var(--space-3)"> |
| 25 | - <div class="card-kicker">Compte</div> | 25 | + <div class="card-kicker">{{ shell.tr.account_kicker }}</div> |
| 26 | <div style="font-size:19px;font-family:var(--font-heading)">{{ shell.username }}</div> | 26 | <div style="font-size:19px;font-family:var(--font-heading)">{{ shell.username }}</div> |
| 27 | <p class="card-body">{{ account_line }}</p> | 27 | <p class="card-body">{{ account_line }}</p> |
| 28 | </div> | 28 | </div> |
| 29 | 29 | ||
| 30 | <a class="card times-link" href="/temps" style="margin-bottom:var(--space-6)"> | 30 | <a class="card times-link" href="/temps" style="margin-bottom:var(--space-6)"> |
| 31 | <span style="flex:1"> | 31 | <span style="flex:1"> |
| 32 | - <span style="display:block;font-family:var(--font-heading);font-size:17px">Mes saisies</span> | 32 | + <span style="display:block;font-family:var(--font-heading);font-size:17px">{{ shell.tr.my_entries }}</span> |
| 33 | - <span style="display:block;font-size:13px;opacity:.7">Consulter et corriger les temps que j'ai enregistrés.</span> | 33 | + <span style="display:block;font-size:13px;opacity:.7">{{ shell.tr.my_entries_desc }}</span> |
| 34 | </span> | 34 | </span> |
| 35 | <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | 35 | <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> |
| 36 | </a> | 36 | </a> |
| @@ -38,16 +38,16 @@ | |||
| 38 | <form method="post" action="/equipes/renommer"> | 38 | <form method="post" action="/equipes/renommer"> |
| 39 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 39 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 40 | <div class="field" style="margin-bottom:var(--space-2)"> | 40 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 41 | - <label for="eq-nom">Nom de l'équipe</label> | 41 | + <label for="eq-nom">{{ shell.tr.team_name_label }}</label> |
| 42 | <div style="display:flex;gap:var(--space-2)"> | 42 | <div style="display:flex;gap:var(--space-2)"> |
| 43 | <input class="input" id="eq-nom" name="nom" value="{{ shell.team_name }}" required style="flex:1;min-height:44px"> | 43 | <input class="input" id="eq-nom" name="nom" value="{{ shell.team_name }}" required style="flex:1;min-height:44px"> |
| 44 | - <button type="submit" class="btn btn-primary" style="min-height:44px">Renommer</button> | 44 | + <button type="submit" class="btn btn-primary" style="min-height:44px">{{ shell.tr.rename }}</button> |
| 45 | </div> | 45 | </div> |
| 46 | </div> | 46 | </div> |
| 47 | </form> | 47 | </form> |
| 48 | -<p style="font-size:12px;opacity:.7;margin:0 0 var(--space-6)">Le nouveau nom est visible par tous les membres.</p> | 48 | +<p style="font-size:12px;opacity:.7;margin:0 0 var(--space-6)">{{ shell.tr.rename_team_note }}</p> |
| 49 | 49 | ||
| 50 | -<h2 style="font-size:16px;margin:0 0 var(--space-2)">Membres de {{ shell.team_name }}</h2> | 50 | +<h2 style="font-size:16px;margin:0 0 var(--space-2)">{{ members_title }}</h2> |
| 51 | <div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-4)"> | 51 | <div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-4)"> |
| 52 | {% for m in members %}<span class="tag tag-neutral">{{ m }}</span>{% endfor %} | 52 | {% for m in members %}<span class="tag tag-neutral">{{ m }}</span>{% endfor %} |
| 53 | </div> | 53 | </div> |
| @@ -55,18 +55,18 @@ | |||
| 55 | <form method="post" action="/equipes/inviter"> | 55 | <form method="post" action="/equipes/inviter"> |
| 56 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 56 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 57 | <div class="field" style="margin-bottom:var(--space-2)"> | 57 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 58 | - <label for="invite-nom">Inviter un utilisateur</label> | 58 | + <label for="invite-nom">{{ shell.tr.invite_user_label }}</label> |
| 59 | <div style="display:flex;gap:var(--space-2)"> | 59 | <div style="display:flex;gap:var(--space-2)"> |
| 60 | - <input class="input" id="invite-nom" name="invite" placeholder="identifiant" autocapitalize="none" autocorrect="off" autocomplete="off" data-1p-ignore required style="flex:1;min-height:44px"> | 60 | + <input class="input" id="invite-nom" name="invite" placeholder="{{ shell.tr.username_ph }}" autocapitalize="none" autocorrect="off" autocomplete="off" data-1p-ignore required style="flex:1;min-height:44px"> |
| 61 | - <button type="submit" class="btn btn-primary" style="min-height:44px">Inviter</button> | 61 | + <button type="submit" class="btn btn-primary" style="min-height:44px">{{ shell.tr.invite }}</button> |
| 62 | </div> | 62 | </div> |
| 63 | </div> | 63 | </div> |
| 64 | </form> | 64 | </form> |
| 65 | {% if let Some(e) = invite_error %}<p class="form-error" style="margin:0 0 var(--space-2)">{{ e }}</p>{% endif %} | 65 | {% if let Some(e) = invite_error %}<p class="form-error" style="margin:0 0 var(--space-2)">{{ e }}</p>{% endif %} |
| 66 | -<p style="font-size:12px;opacity:.7;margin:0">L'utilisateur doit déjà avoir un compte. Il reçoit une invitation à accepter de son côté.</p> | 66 | +<p style="font-size:12px;opacity:.7;margin:0">{{ shell.tr.invite_note }}</p> |
| 67 | 67 | ||
| 68 | {% if !sent.is_empty() %} | 68 | {% if !sent.is_empty() %} |
| 69 | -<h2 style="font-size:16px;margin:var(--space-6) 0 var(--space-2)">Invitations en attente</h2> | 69 | +<h2 style="font-size:16px;margin:var(--space-6) 0 var(--space-2)">{{ shell.tr.pending_invites }}</h2> |
| 70 | {% for si in sent %} | 70 | {% for si in sent %} |
| 71 | <div class="sent-row"> | 71 | <div class="sent-row"> |
| 72 | <div style="flex:1;min-width:0"> | 72 | <div style="flex:1;min-width:0"> |
| @@ -75,54 +75,67 @@ | |||
| 75 | </div> | 75 | </div> |
| 76 | <form method="post" action="{{ si.cancel_action }}" style="margin:0"> | 76 | <form method="post" action="{{ si.cancel_action }}" style="margin:0"> |
| 77 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 77 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 78 | - <button type="submit" class="btn btn-ghost" style="font-size:12px">Annuler</button> | 78 | + <button type="submit" class="btn btn-ghost" style="font-size:12px">{{ shell.tr.cancel }}</button> |
| 79 | </form> | 79 | </form> |
| 80 | </div> | 80 | </div> |
| 81 | {% endfor %} | 81 | {% endfor %} |
| 82 | {% endif %} | 82 | {% endif %} |
| 83 | 83 | ||
| 84 | -<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-2)">Créer une équipe</h2> | 84 | +<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-2)">{{ shell.tr.create_team }}</h2> |
| 85 | <form method="post" action="/equipes"> | 85 | <form method="post" action="/equipes"> |
| 86 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 86 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 87 | <div style="display:flex;gap:var(--space-2)"> | 87 | <div style="display:flex;gap:var(--space-2)"> |
| 88 | - <input class="input" name="nom" placeholder="Nom de l'équipe" required aria-label="Nom de la nouvelle équipe" style="flex:1;min-height:44px"> | 88 | + <input class="input" name="nom" placeholder="{{ shell.tr.team_name_label }}" required aria-label="{{ shell.tr.new_team_aria }}" style="flex:1;min-height:44px"> |
| 89 | - <button type="submit" class="btn btn-primary" style="min-height:44px">Créer</button> | 89 | + <button type="submit" class="btn btn-primary" style="min-height:44px">{{ shell.tr.create }}</button> |
| 90 | </div> | 90 | </div> |
| 91 | </form> | 91 | </form> |
| 92 | -<p style="font-size:12px;opacity:.7;margin:var(--space-2) 0 0">Tu bascules dessus aussitôt — invite ensuite qui tu veux.</p> | 92 | +<p style="font-size:12px;opacity:.7;margin:var(--space-2) 0 0">{{ shell.tr.create_team_note }}</p> |
| 93 | 93 | ||
| 94 | {% if can_leave %} | 94 | {% if can_leave %} |
| 95 | <form method="post" action="/equipes/quitter"> | 95 | <form method="post" action="/equipes/quitter"> |
| 96 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 96 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 97 | <button type="submit" class="btn btn-block" | 97 | <button type="submit" class="btn btn-block" |
| 98 | - style="min-height:46px;margin-top:var(--space-6);color:var(--color-accent-700);border:1px solid var(--color-accent-300)">Quitter « {{ shell.team_name }} »</button> | 98 | + style="min-height:46px;margin-top:var(--space-6);color:var(--color-accent-700);border:1px solid var(--color-accent-300)">{{ leave_label }}</button> |
| 99 | </form> | 99 | </form> |
| 100 | -<p style="font-size:11px;opacity:.7;margin-top:var(--space-2);text-align:center">Tes temps déjà saisis restent dans les stats de l'équipe.</p> | 100 | +<p style="font-size:11px;opacity:.7;margin-top:var(--space-2);text-align:center">{{ shell.tr.leave_note }}</p> |
| 101 | {% endif %} | 101 | {% endif %} |
| 102 | 102 | ||
| 103 | -<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">Session</h2> | 103 | +<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-2)">{{ shell.tr.language }}</h2> |
| 104 | +<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-2)"> | ||
| 105 | + {% for l in langs %} | ||
| 106 | + <form method="post" action="/parametres/langue" style="margin:0"> | ||
| 107 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | ||
| 108 | + <input type="hidden" name="langue" value="{{ l.code }}"> | ||
| 109 | + <button type="submit" class="lang-pill{% if l.current %} current{% endif %}" {% if l.current %}aria-current="true"{% endif %} lang="{{ l.code }}"> | ||
| 110 | + {{ l.name }}{% if l.current %} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"></path></svg>{% endif %} | ||
| 111 | + </button> | ||
| 112 | + </form> | ||
| 113 | + {% endfor %} | ||
| 114 | +</div> | ||
| 115 | + | ||
| 116 | +<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2> | ||
| 104 | <form method="post" action="/parametres/mot-de-passe"> | 117 | <form method="post" action="/parametres/mot-de-passe"> |
| 105 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 118 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 106 | <div class="field" style="margin-bottom:var(--space-3)"> | 119 | <div class="field" style="margin-bottom:var(--space-3)"> |
| 107 | - <label for="ancien">Mot de passe actuel</label> | 120 | + <label for="ancien">{{ shell.tr.current_password }}</label> |
| 108 | <input class="input" id="ancien" name="ancien" type="password" autocomplete="current-password" required style="min-height:44px"> | 121 | <input class="input" id="ancien" name="ancien" type="password" autocomplete="current-password" required style="min-height:44px"> |
| 109 | </div> | 122 | </div> |
| 110 | <div class="field" style="margin-bottom:var(--space-3)"> | 123 | <div class="field" style="margin-bottom:var(--space-3)"> |
| 111 | - <label for="nouveau">Nouveau mot de passe</label> | 124 | + <label for="nouveau">{{ shell.tr.new_password }}</label> |
| 112 | <input class="input" id="nouveau" name="nouveau" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px"> | 125 | <input class="input" id="nouveau" name="nouveau" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px"> |
| 113 | </div> | 126 | </div> |
| 114 | <div class="field" style="margin-bottom:var(--space-4)"> | 127 | <div class="field" style="margin-bottom:var(--space-4)"> |
| 115 | - <label for="confirmation">Confirmation</label> | 128 | + <label for="confirmation">{{ shell.tr.confirmation }}</label> |
| 116 | <input class="input" id="confirmation" name="confirmation" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px"> | 129 | <input class="input" id="confirmation" name="confirmation" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px"> |
| 117 | </div> | 130 | </div> |
| 118 | {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %} | 131 | {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %} |
| 119 | - <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">Changer le mot de passe</button> | 132 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.change_password }}</button> |
| 120 | </form> | 133 | </form> |
| 121 | 134 | ||
| 122 | <form method="post" action="/logout"> | 135 | <form method="post" action="/logout"> |
| 123 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 136 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 124 | <button type="submit" class="btn btn-block" | 137 | <button type="submit" class="btn btn-block" |
| 125 | - style="min-height:46px;margin-top:var(--space-4);color:var(--color-accent-700);border:1px solid var(--color-accent-300)">Se déconnecter</button> | 138 | + style="min-height:46px;margin-top:var(--space-4);color:var(--color-accent-700);border:1px solid var(--color-accent-300)">{{ shell.tr.sign_out }}</button> |
| 126 | </form> | 139 | </form> |
| 127 | -<p class="text-muted" style="font-size:11px;margin-top:var(--space-3);text-align:center">Le biscuit de session sera révoqué.</p> | 140 | +<p class="text-muted" style="font-size:11px;margin-top:var(--space-3);text-align:center">{{ shell.tr.signout_note }}</p> |
| 128 | {% endblock %} | 141 | {% endblock %} |
modified
templates/stats.html +8 -8 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 2 | {% block content %} |
| 3 | -<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0;font-size:13px" href="/temps">Mes saisies →</a> | |
| 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0;font-size:13px" href="/temps">{{ shell.tr.my_entries_arrow }}</a> | |
| 4 | 4 | {% if !legend.is_empty() %} |
| 5 | 5 | <ul class="legend"> |
| 6 | 6 | {% for item in legend %} |
| @@ -15,11 +15,11 @@ | ||
| 15 | 15 | <h2>{{ c.title }}</h2> |
| 16 | 16 | {% if c.nav %} |
| 17 | 17 | <div class="chart-nav"> |
| 18 | - <a class="btn btn-secondary btn-icon-sm" href="{{ c.prev }}" aria-label="Période précédente"> | |
| 18 | + <a class="btn btn-secondary btn-icon-sm" href="{{ c.prev }}" aria-label="{{ shell.tr.prev_period }}"> | |
| 19 | 19 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> |
| 20 | 20 | </a> |
| 21 | 21 | {% if let Some(next) = c.next %} |
| 22 | - <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="Période suivante"> | |
| 22 | + <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="{{ shell.tr.next_period }}"> | |
| 23 | 23 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> |
| 24 | 24 | </a> |
| 25 | 25 | {% else %} |
| @@ -33,7 +33,7 @@ | ||
| 33 | 33 | <p class="chart-range">{{ c.range }}</p> |
| 34 | 34 | |
| 35 | 35 | {% if c.empty %} |
| 36 | - <p class="chart-empty">Aucun temps enregistré sur cette période.</p> | |
| 36 | + <p class="chart-empty">{{ shell.tr.no_time_period }}</p> | |
| 37 | 37 | {% else %} |
| 38 | 38 | <div class="cols"> |
| 39 | 39 | {% for row in c.rows %} |
| @@ -54,17 +54,17 @@ | ||
| 54 | 54 | {% endfor %} |
| 55 | 55 | |
| 56 | 56 | {% for s in sheets %} |
| 57 | -<div class="sheet-wrap" id="{{ s.id }}" role="dialog" aria-label="Détail — {{ s.category }}"> | |
| 58 | - <a class="sheet-backdrop" href="#fermer" aria-label="Fermer"></a> | |
| 57 | +<div class="sheet-wrap" id="{{ s.id }}" role="dialog" aria-label="{{ shell.tr.detail }} — {{ s.category }}"> | |
| 58 | + <a class="sheet-backdrop" href="#fermer" aria-label="{{ shell.tr.close }}"></a> | |
| 59 | 59 | <div class="sheet"> |
| 60 | 60 | <div class="sheet-head"> |
| 61 | 61 | <span class="dot dot-14" style="background:{{ s.color }}"></span> |
| 62 | 62 | <span class="pname">{{ s.category }}</span> |
| 63 | - <a class="btn btn-ghost close" href="#fermer">Fermer</a> | |
| 63 | + <a class="btn btn-ghost close" href="#fermer">{{ shell.tr.close }}</a> | |
| 64 | 64 | </div> |
| 65 | 65 | <div class="sheet-figures"> |
| 66 | 66 | <div><div class="k">{{ s.who }}</div><div class="v">{{ s.duration }}</div></div> |
| 67 | - <div><div class="k">Part de son temps</div><div class="v">{{ s.share }}</div></div> | |
| 67 | + <div><div class="k">{{ shell.tr.share_of_time }}</div><div class="v">{{ s.share }}</div></div> | |
| 68 | 68 | </div> |
| 69 | 69 | <p class="sheet-note">{{ s.period }} · {{ s.count }}</p> |
| 70 | 70 | </div> |
| @@ -1,6 +1,6 @@ | |||
| 1 | {% extends "app.html" %} | 1 | {% extends "app.html" %} |
| 2 | {% block content %} | 2 | {% block content %} |
| 3 | -<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0;font-size:13px" href="/temps">Mes saisies →</a> | 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0;font-size:13px" href="/temps">{{ shell.tr.my_entries_arrow }}</a> |
| 4 | {% if !legend.is_empty() %} | 4 | {% if !legend.is_empty() %} |
| 5 | <ul class="legend"> | 5 | <ul class="legend"> |
| 6 | {% for item in legend %} | 6 | {% for item in legend %} |
| @@ -15,11 +15,11 @@ | |||
| 15 | <h2>{{ c.title }}</h2> | 15 | <h2>{{ c.title }}</h2> |
| 16 | {% if c.nav %} | 16 | {% if c.nav %} |
| 17 | <div class="chart-nav"> | 17 | <div class="chart-nav"> |
| 18 | - <a class="btn btn-secondary btn-icon-sm" href="{{ c.prev }}" aria-label="Période précédente"> | 18 | + <a class="btn btn-secondary btn-icon-sm" href="{{ c.prev }}" aria-label="{{ shell.tr.prev_period }}"> |
| 19 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> | 19 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> |
| 20 | </a> | 20 | </a> |
| 21 | {% if let Some(next) = c.next %} | 21 | {% if let Some(next) = c.next %} |
| 22 | - <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="Période suivante"> | 22 | + <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="{{ shell.tr.next_period }}"> |
| 23 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | 23 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> |
| 24 | </a> | 24 | </a> |
| 25 | {% else %} | 25 | {% else %} |
| @@ -33,7 +33,7 @@ | |||
| 33 | <p class="chart-range">{{ c.range }}</p> | 33 | <p class="chart-range">{{ c.range }}</p> |
| 34 | 34 | ||
| 35 | {% if c.empty %} | 35 | {% if c.empty %} |
| 36 | - <p class="chart-empty">Aucun temps enregistré sur cette période.</p> | 36 | + <p class="chart-empty">{{ shell.tr.no_time_period }}</p> |
| 37 | {% else %} | 37 | {% else %} |
| 38 | <div class="cols"> | 38 | <div class="cols"> |
| 39 | {% for row in c.rows %} | 39 | {% for row in c.rows %} |
| @@ -54,17 +54,17 @@ | |||
| 54 | {% endfor %} | 54 | {% endfor %} |
| 55 | 55 | ||
| 56 | {% for s in sheets %} | 56 | {% for s in sheets %} |
| 57 | -<div class="sheet-wrap" id="{{ s.id }}" role="dialog" aria-label="Détail — {{ s.category }}"> | 57 | +<div class="sheet-wrap" id="{{ s.id }}" role="dialog" aria-label="{{ shell.tr.detail }} — {{ s.category }}"> |
| 58 | - <a class="sheet-backdrop" href="#fermer" aria-label="Fermer"></a> | 58 | + <a class="sheet-backdrop" href="#fermer" aria-label="{{ shell.tr.close }}"></a> |
| 59 | <div class="sheet"> | 59 | <div class="sheet"> |
| 60 | <div class="sheet-head"> | 60 | <div class="sheet-head"> |
| 61 | <span class="dot dot-14" style="background:{{ s.color }}"></span> | 61 | <span class="dot dot-14" style="background:{{ s.color }}"></span> |
| 62 | <span class="pname">{{ s.category }}</span> | 62 | <span class="pname">{{ s.category }}</span> |
| 63 | - <a class="btn btn-ghost close" href="#fermer">Fermer</a> | 63 | + <a class="btn btn-ghost close" href="#fermer">{{ shell.tr.close }}</a> |
| 64 | </div> | 64 | </div> |
| 65 | <div class="sheet-figures"> | 65 | <div class="sheet-figures"> |
| 66 | <div><div class="k">{{ s.who }}</div><div class="v">{{ s.duration }}</div></div> | 66 | <div><div class="k">{{ s.who }}</div><div class="v">{{ s.duration }}</div></div> |
| 67 | - <div><div class="k">Part de son temps</div><div class="v">{{ s.share }}</div></div> | 67 | + <div><div class="k">{{ shell.tr.share_of_time }}</div><div class="v">{{ s.share }}</div></div> |
| 68 | </div> | 68 | </div> |
| 69 | <p class="sheet-note">{{ s.period }} · {{ s.count }}</p> | 69 | <p class="sheet-note">{{ s.period }} · {{ s.count }}</p> |
| 70 | </div> | 70 | </div> |
modified
templates/timer.html +5 -5 | @@ -1,19 +1,19 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 2 | {% block content %} |
| 3 | 3 | <div class="chrono-card"> |
| 4 | - <p class="chrono-kicker">Minuteur en cours</p> | |
| 4 | + <p class="chrono-kicker">{{ shell.tr.chrono_kicker }}</p> | |
| 5 | 5 | <p class="chrono-time" data-started="{{ started_ms }}">{{ live_label }}</p> |
| 6 | - <p class="chrono-start">Démarré à {{ start_clock }}</p> | |
| 6 | + <p class="chrono-start">{{ started_line }}</p> | |
| 7 | 7 | </div> |
| 8 | -<p class="chrono-hint">Termine le minuteur pour choisir la catégorie et enregistrer ce temps.</p> | |
| 8 | +<p class="chrono-hint">{{ shell.tr.chrono_hint }}</p> | |
| 9 | 9 | <div style="display:flex;flex-direction:column;gap:var(--space-3)"> |
| 10 | 10 | <form method="post" action="/timer/arreter" style="margin:0;display:flex"> |
| 11 | 11 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 12 | - <button type="submit" class="btn btn-primary btn-block" style="flex:1;min-height:52px">Finir le minuteur</button> | |
| 12 | + <button type="submit" class="btn btn-primary btn-block" style="flex:1;min-height:52px">{{ shell.tr.finish_timer }}</button> | |
| 13 | 13 | </form> |
| 14 | 14 | <form method="post" action="/timer/abandonner" style="margin:0;display:flex"> |
| 15 | 15 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 16 | - <button type="submit" class="btn btn-secondary btn-block" style="flex:1;min-height:48px">Annuler le minuteur</button> | |
| 16 | + <button type="submit" class="btn btn-secondary btn-block" style="flex:1;min-height:48px">{{ shell.tr.cancel_timer }}</button> | |
| 17 | 17 | </form> |
| 18 | 18 | </div> |
| 19 | 19 | <script> |
| @@ -1,19 +1,19 @@ | |||
| 1 | {% extends "app.html" %} | 1 | {% extends "app.html" %} |
| 2 | {% block content %} | 2 | {% block content %} |
| 3 | <div class="chrono-card"> | 3 | <div class="chrono-card"> |
| 4 | - <p class="chrono-kicker">Minuteur en cours</p> | 4 | + <p class="chrono-kicker">{{ shell.tr.chrono_kicker }}</p> |
| 5 | <p class="chrono-time" data-started="{{ started_ms }}">{{ live_label }}</p> | 5 | <p class="chrono-time" data-started="{{ started_ms }}">{{ live_label }}</p> |
| 6 | - <p class="chrono-start">Démarré à {{ start_clock }}</p> | 6 | + <p class="chrono-start">{{ started_line }}</p> |
| 7 | </div> | 7 | </div> |
| 8 | -<p class="chrono-hint">Termine le minuteur pour choisir la catégorie et enregistrer ce temps.</p> | 8 | +<p class="chrono-hint">{{ shell.tr.chrono_hint }}</p> |
| 9 | <div style="display:flex;flex-direction:column;gap:var(--space-3)"> | 9 | <div style="display:flex;flex-direction:column;gap:var(--space-3)"> |
| 10 | <form method="post" action="/timer/arreter" style="margin:0;display:flex"> | 10 | <form method="post" action="/timer/arreter" style="margin:0;display:flex"> |
| 11 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | 11 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 12 | - <button type="submit" class="btn btn-primary btn-block" style="flex:1;min-height:52px">Finir le minuteur</button> | 12 | + <button type="submit" class="btn btn-primary btn-block" style="flex:1;min-height:52px">{{ shell.tr.finish_timer }}</button> |
| 13 | </form> | 13 | </form> |
| 14 | <form method="post" action="/timer/abandonner" style="margin:0;display:flex"> | 14 | <form method="post" action="/timer/abandonner" style="margin:0;display:flex"> |
| 15 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | 15 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 16 | - <button type="submit" class="btn btn-secondary btn-block" style="flex:1;min-height:48px">Annuler le minuteur</button> | 16 | + <button type="submit" class="btn btn-secondary btn-block" style="flex:1;min-height:48px">{{ shell.tr.cancel_timer }}</button> |
| 17 | </form> | 17 | </form> |
| 18 | </div> | 18 | </div> |
| 19 | <script> | 19 | <script> |
modified
templates/times.html +3 -3 | @@ -1,7 +1,7 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 2 | {% block content %} |
| 3 | 3 | {% if entries.is_empty() %} |
| 4 | -<p class="chart-empty">Tu n'as encore enregistré aucun temps dans cette équipe.</p> | |
| 4 | +<p class="chart-empty">{{ shell.tr.times_empty }}</p> | |
| 5 | 5 | {% else %} |
| 6 | 6 | <ul class="times"> |
| 7 | 7 | {% for e in entries %} |
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | <div class="tdate">{{ e.date }}</div> |
| 13 | 13 | </div> |
| 14 | 14 | <span class="tdur">{{ e.duration }}</span> |
| 15 | - <a class="btn btn-secondary btn-icon-sm" href="{{ e.edit_href }}" aria-label="Modifier"> | |
| 15 | + <a class="btn btn-secondary btn-icon-sm" href="{{ e.edit_href }}" aria-label="{{ shell.tr.edit_aria }}"> | |
| 16 | 16 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> |
| 17 | 17 | </a> |
| 18 | 18 | </li> |
| @@ -20,6 +20,6 @@ | ||
| 20 | 20 | </ul> |
| 21 | 21 | {% endif %} |
| 22 | 22 | {% if let Some(more) = more_href %} |
| 23 | -<a class="btn btn-secondary btn-block" style="margin-top:var(--space-4)" href="{{ more }}">Voir plus</a> | |
| 23 | +<a class="btn btn-secondary btn-block" style="margin-top:var(--space-4)" href="{{ more }}">{{ shell.tr.see_more }}</a> | |
| 24 | 24 | {% endif %} |
| 25 | 25 | {% endblock %} |
| @@ -1,7 +1,7 @@ | |||
| 1 | {% extends "app.html" %} | 1 | {% extends "app.html" %} |
| 2 | {% block content %} | 2 | {% block content %} |
| 3 | {% if entries.is_empty() %} | 3 | {% if entries.is_empty() %} |
| 4 | -<p class="chart-empty">Tu n'as encore enregistré aucun temps dans cette équipe.</p> | 4 | +<p class="chart-empty">{{ shell.tr.times_empty }}</p> |
| 5 | {% else %} | 5 | {% else %} |
| 6 | <ul class="times"> | 6 | <ul class="times"> |
| 7 | {% for e in entries %} | 7 | {% for e in entries %} |
| @@ -12,7 +12,7 @@ | |||
| 12 | <div class="tdate">{{ e.date }}</div> | 12 | <div class="tdate">{{ e.date }}</div> |
| 13 | </div> | 13 | </div> |
| 14 | <span class="tdur">{{ e.duration }}</span> | 14 | <span class="tdur">{{ e.duration }}</span> |
| 15 | - <a class="btn btn-secondary btn-icon-sm" href="{{ e.edit_href }}" aria-label="Modifier"> | 15 | + <a class="btn btn-secondary btn-icon-sm" href="{{ e.edit_href }}" aria-label="{{ shell.tr.edit_aria }}"> |
| 16 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> | 16 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> |
| 17 | </a> | 17 | </a> |
| 18 | </li> | 18 | </li> |
| @@ -20,6 +20,6 @@ | |||
| 20 | </ul> | 20 | </ul> |
| 21 | {% endif %} | 21 | {% endif %} |
| 22 | {% if let Some(more) = more_href %} | 22 | {% if let Some(more) = more_href %} |
| 23 | -<a class="btn btn-secondary btn-block" style="margin-top:var(--space-4)" href="{{ more }}">Voir plus</a> | 23 | +<a class="btn btn-secondary btn-block" style="margin-top:var(--space-4)" href="{{ more }}">{{ shell.tr.see_more }}</a> |
| 24 | {% endif %} | 24 | {% endif %} |
| 25 | {% endblock %} | 25 | {% endblock %} |
modified
templates/todo_done.html +16 -16 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 2 | {% block content %} |
| 3 | -<a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0" href="/taches">← Tâches</a> | |
| 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0" href="/taches">{{ shell.tr.back_todos }}</a> | |
| 4 | 4 | |
| 5 | 5 | <div class="done-title"> |
| 6 | 6 | <span class="done-check" aria-hidden="true"> |
| @@ -15,9 +15,9 @@ | ||
| 15 | 15 | |
| 16 | 16 | <section class="done-sec"> |
| 17 | 17 | <div class="done-head"> |
| 18 | - <h2>Durée passée</h2> | |
| 18 | + <h2>{{ shell.tr.duration_spent }}</h2> | |
| 19 | 19 | <label class="swt-check"> |
| 20 | - Enregistrer un temps | |
| 20 | + {{ shell.tr.record_time }} | |
| 21 | 21 | <input type="checkbox" name="avec_temps" value="1" checked> |
| 22 | 22 | <span class="swt"><span class="knob"></span></span> |
| 23 | 23 | </label> |
| @@ -25,17 +25,17 @@ | ||
| 25 | 25 | <div class="done-body"> |
| 26 | 26 | <div class="duration-row"> |
| 27 | 27 | <div class="stepper"> |
| 28 | - <a class="stepper-btn" href="{{ minus_href }}" data-step="-5" aria-label="Cinq minutes de moins"> | |
| 28 | + <a class="stepper-btn" href="{{ minus_href }}" data-step="-5" aria-label="{{ shell.tr.five_less }}"> | |
| 29 | 29 | <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg> |
| 30 | 30 | </a> |
| 31 | 31 | <input class="input minutes-input" id="minutes" name="minutes" type="number" inputmode="numeric" |
| 32 | - min="1" required value="{{ minutes }}" aria-label="Minutes passées"> | |
| 33 | - <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="Cinq minutes de plus"> | |
| 32 | + min="1" required value="{{ minutes }}" aria-label="{{ shell.tr.minutes_spent_aria }}"> | |
| 33 | + <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="{{ shell.tr.five_more }}"> | |
| 34 | 34 | <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg> |
| 35 | 35 | </a> |
| 36 | 36 | </div> |
| 37 | 37 | <div> |
| 38 | - <div class="unit">minutes</div> | |
| 38 | + <div class="unit">{{ shell.tr.minutes_unit }}</div> | |
| 39 | 39 | <div class="pretty" id="minutes-pretty">{{ minutes_pretty }}</div> |
| 40 | 40 | </div> |
| 41 | 41 | </div> |
| @@ -44,14 +44,14 @@ | ||
| 44 | 44 | <a class="preset" href="{{ p.href }}" data-minutes="{{ p.minutes }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a> |
| 45 | 45 | {% endfor %} |
| 46 | 46 | </div> |
| 47 | - <label class="mini-label" for="done-date" style="margin-top:var(--space-3)">Date du temps</label> | |
| 47 | + <label class="mini-label" for="done-date" style="margin-top:var(--space-3)">{{ shell.tr.time_date }}</label> | |
| 48 | 48 | <div class="date-row"> |
| 49 | - <a class="btn btn-secondary btn-icon" href="{{ day_prev }}" data-date-step="-1" data-date-for="done-date" aria-label="Jour précédent"> | |
| 49 | + <a class="btn btn-secondary btn-icon" href="{{ day_prev }}" data-date-step="-1" data-date-for="done-date" aria-label="{{ shell.tr.day_prev }}"> | |
| 50 | 50 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> |
| 51 | 51 | </a> |
| 52 | 52 | <input class="input" id="done-date" name="date" type="date" value="{{ date }}" max="{{ today }}" required> |
| 53 | 53 | {% if let Some(next_href) = day_next %} |
| 54 | - <a class="btn btn-secondary btn-icon" href="{{ next_href }}" data-date-step="1" data-date-for="done-date" aria-label="Jour suivant"> | |
| 54 | + <a class="btn btn-secondary btn-icon" href="{{ next_href }}" data-date-step="1" data-date-for="done-date" aria-label="{{ shell.tr.day_next }}"> | |
| 55 | 55 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> |
| 56 | 56 | </a> |
| 57 | 57 | {% else %} |
| @@ -65,9 +65,9 @@ | ||
| 65 | 65 | |
| 66 | 66 | <section class="done-sec" style="margin-top:var(--space-8)"> |
| 67 | 67 | <div class="done-head"> |
| 68 | - <h2>Reprogrammer</h2> | |
| 68 | + <h2>{{ shell.tr.reschedule }}</h2> | |
| 69 | 69 | <label class="swt-check"> |
| 70 | - Reprogrammer | |
| 70 | + {{ shell.tr.reschedule }} | |
| 71 | 71 | <input type="checkbox" name="reprogrammer" value="1" checked> |
| 72 | 72 | <span class="swt"><span class="knob"></span></span> |
| 73 | 73 | </label> |
| @@ -75,11 +75,11 @@ | ||
| 75 | 75 | <div class="done-body"> |
| 76 | 76 | <p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ rhythm }}</p> |
| 77 | 77 | <div class="date-row"> |
| 78 | - <a class="btn btn-secondary btn-icon" href="{{ next_prev }}" data-date-step="-1" data-date-for="next-date" aria-label="Jour précédent"> | |
| 78 | + <a class="btn btn-secondary btn-icon" href="{{ next_prev }}" data-date-step="-1" data-date-for="next-date" aria-label="{{ shell.tr.day_prev }}"> | |
| 79 | 79 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> |
| 80 | 80 | </a> |
| 81 | - <input class="input" id="next-date" name="prochaine" type="date" value="{{ next }}" required aria-label="Prochaine activation"> | |
| 82 | - <a class="btn btn-secondary btn-icon" href="{{ next_next }}" data-date-step="1" data-date-for="next-date" aria-label="Jour suivant"> | |
| 81 | + <input class="input" id="next-date" name="prochaine" type="date" value="{{ next }}" required aria-label="{{ shell.tr.next_activation_aria }}"> | |
| 82 | + <a class="btn btn-secondary btn-icon" href="{{ next_next }}" data-date-step="1" data-date-for="next-date" aria-label="{{ shell.tr.day_next }}"> | |
| 83 | 83 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> |
| 84 | 84 | </a> |
| 85 | 85 | </div> |
| @@ -87,7 +87,7 @@ | ||
| 87 | 87 | </div> |
| 88 | 88 | </section> |
| 89 | 89 | |
| 90 | - <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px;margin-top:var(--space-8)">Valider</button> | |
| 90 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px;margin-top:var(--space-8)">{{ shell.tr.validate }}</button> | |
| 91 | 91 | </form> |
| 92 | 92 | |
| 93 | 93 | {% endblock %} |
| @@ -1,6 +1,6 @@ | |||
| 1 | {% extends "app.html" %} | 1 | {% extends "app.html" %} |
| 2 | {% block content %} | 2 | {% block content %} |
| 3 | -<a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0" href="/taches">← Tâches</a> | 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0" href="/taches">{{ shell.tr.back_todos }}</a> |
| 4 | 4 | ||
| 5 | <div class="done-title"> | 5 | <div class="done-title"> |
| 6 | <span class="done-check" aria-hidden="true"> | 6 | <span class="done-check" aria-hidden="true"> |
| @@ -15,9 +15,9 @@ | |||
| 15 | 15 | ||
| 16 | <section class="done-sec"> | 16 | <section class="done-sec"> |
| 17 | <div class="done-head"> | 17 | <div class="done-head"> |
| 18 | - <h2>Durée passée</h2> | 18 | + <h2>{{ shell.tr.duration_spent }}</h2> |
| 19 | <label class="swt-check"> | 19 | <label class="swt-check"> |
| 20 | - Enregistrer un temps | 20 | + {{ shell.tr.record_time }} |
| 21 | <input type="checkbox" name="avec_temps" value="1" checked> | 21 | <input type="checkbox" name="avec_temps" value="1" checked> |
| 22 | <span class="swt"><span class="knob"></span></span> | 22 | <span class="swt"><span class="knob"></span></span> |
| 23 | </label> | 23 | </label> |
| @@ -25,17 +25,17 @@ | |||
| 25 | <div class="done-body"> | 25 | <div class="done-body"> |
| 26 | <div class="duration-row"> | 26 | <div class="duration-row"> |
| 27 | <div class="stepper"> | 27 | <div class="stepper"> |
| 28 | - <a class="stepper-btn" href="{{ minus_href }}" data-step="-5" aria-label="Cinq minutes de moins"> | 28 | + <a class="stepper-btn" href="{{ minus_href }}" data-step="-5" aria-label="{{ shell.tr.five_less }}"> |
| 29 | <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg> | 29 | <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg> |
| 30 | </a> | 30 | </a> |
| 31 | <input class="input minutes-input" id="minutes" name="minutes" type="number" inputmode="numeric" | 31 | <input class="input minutes-input" id="minutes" name="minutes" type="number" inputmode="numeric" |
| 32 | - min="1" required value="{{ minutes }}" aria-label="Minutes passées"> | 32 | + min="1" required value="{{ minutes }}" aria-label="{{ shell.tr.minutes_spent_aria }}"> |
| 33 | - <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="Cinq minutes de plus"> | 33 | + <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="{{ shell.tr.five_more }}"> |
| 34 | <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg> | 34 | <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg> |
| 35 | </a> | 35 | </a> |
| 36 | </div> | 36 | </div> |
| 37 | <div> | 37 | <div> |
| 38 | - <div class="unit">minutes</div> | 38 | + <div class="unit">{{ shell.tr.minutes_unit }}</div> |
| 39 | <div class="pretty" id="minutes-pretty">{{ minutes_pretty }}</div> | 39 | <div class="pretty" id="minutes-pretty">{{ minutes_pretty }}</div> |
| 40 | </div> | 40 | </div> |
| 41 | </div> | 41 | </div> |
| @@ -44,14 +44,14 @@ | |||
| 44 | <a class="preset" href="{{ p.href }}" data-minutes="{{ p.minutes }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a> | 44 | <a class="preset" href="{{ p.href }}" data-minutes="{{ p.minutes }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a> |
| 45 | {% endfor %} | 45 | {% endfor %} |
| 46 | </div> | 46 | </div> |
| 47 | - <label class="mini-label" for="done-date" style="margin-top:var(--space-3)">Date du temps</label> | 47 | + <label class="mini-label" for="done-date" style="margin-top:var(--space-3)">{{ shell.tr.time_date }}</label> |
| 48 | <div class="date-row"> | 48 | <div class="date-row"> |
| 49 | - <a class="btn btn-secondary btn-icon" href="{{ day_prev }}" data-date-step="-1" data-date-for="done-date" aria-label="Jour précédent"> | 49 | + <a class="btn btn-secondary btn-icon" href="{{ day_prev }}" data-date-step="-1" data-date-for="done-date" aria-label="{{ shell.tr.day_prev }}"> |
| 50 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> | 50 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> |
| 51 | </a> | 51 | </a> |
| 52 | <input class="input" id="done-date" name="date" type="date" value="{{ date }}" max="{{ today }}" required> | 52 | <input class="input" id="done-date" name="date" type="date" value="{{ date }}" max="{{ today }}" required> |
| 53 | {% if let Some(next_href) = day_next %} | 53 | {% if let Some(next_href) = day_next %} |
| 54 | - <a class="btn btn-secondary btn-icon" href="{{ next_href }}" data-date-step="1" data-date-for="done-date" aria-label="Jour suivant"> | 54 | + <a class="btn btn-secondary btn-icon" href="{{ next_href }}" data-date-step="1" data-date-for="done-date" aria-label="{{ shell.tr.day_next }}"> |
| 55 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | 55 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> |
| 56 | </a> | 56 | </a> |
| 57 | {% else %} | 57 | {% else %} |
| @@ -65,9 +65,9 @@ | |||
| 65 | 65 | ||
| 66 | <section class="done-sec" style="margin-top:var(--space-8)"> | 66 | <section class="done-sec" style="margin-top:var(--space-8)"> |
| 67 | <div class="done-head"> | 67 | <div class="done-head"> |
| 68 | - <h2>Reprogrammer</h2> | 68 | + <h2>{{ shell.tr.reschedule }}</h2> |
| 69 | <label class="swt-check"> | 69 | <label class="swt-check"> |
| 70 | - Reprogrammer | 70 | + {{ shell.tr.reschedule }} |
| 71 | <input type="checkbox" name="reprogrammer" value="1" checked> | 71 | <input type="checkbox" name="reprogrammer" value="1" checked> |
| 72 | <span class="swt"><span class="knob"></span></span> | 72 | <span class="swt"><span class="knob"></span></span> |
| 73 | </label> | 73 | </label> |
| @@ -75,11 +75,11 @@ | |||
| 75 | <div class="done-body"> | 75 | <div class="done-body"> |
| 76 | <p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ rhythm }}</p> | 76 | <p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ rhythm }}</p> |
| 77 | <div class="date-row"> | 77 | <div class="date-row"> |
| 78 | - <a class="btn btn-secondary btn-icon" href="{{ next_prev }}" data-date-step="-1" data-date-for="next-date" aria-label="Jour précédent"> | 78 | + <a class="btn btn-secondary btn-icon" href="{{ next_prev }}" data-date-step="-1" data-date-for="next-date" aria-label="{{ shell.tr.day_prev }}"> |
| 79 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> | 79 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> |
| 80 | </a> | 80 | </a> |
| 81 | - <input class="input" id="next-date" name="prochaine" type="date" value="{{ next }}" required aria-label="Prochaine activation"> | 81 | + <input class="input" id="next-date" name="prochaine" type="date" value="{{ next }}" required aria-label="{{ shell.tr.next_activation_aria }}"> |
| 82 | - <a class="btn btn-secondary btn-icon" href="{{ next_next }}" data-date-step="1" data-date-for="next-date" aria-label="Jour suivant"> | 82 | + <a class="btn btn-secondary btn-icon" href="{{ next_next }}" data-date-step="1" data-date-for="next-date" aria-label="{{ shell.tr.day_next }}"> |
| 83 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | 83 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> |
| 84 | </a> | 84 | </a> |
| 85 | </div> | 85 | </div> |
| @@ -87,7 +87,7 @@ | |||
| 87 | </div> | 87 | </div> |
| 88 | </section> | 88 | </section> |
| 89 | 89 | ||
| 90 | - <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px;margin-top:var(--space-8)">Valider</button> | 90 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px;margin-top:var(--space-8)">{{ shell.tr.validate }}</button> |
| 91 | </form> | 91 | </form> |
| 92 | 92 | ||
| 93 | {% endblock %} | 93 | {% endblock %} |
modified
templates/todo_form.html +11 -11 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 2 | {% block content %} |
| 3 | -<a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0" href="/taches">← Tâches</a> | |
| 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0" href="/taches">{{ shell.tr.back_todos }}</a> | |
| 4 | 4 | |
| 5 | 5 | {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %} |
| 6 | 6 | |
| @@ -8,13 +8,13 @@ | ||
| 8 | 8 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 9 | 9 | |
| 10 | 10 | <div class="field" style="margin-bottom:var(--space-4)"> |
| 11 | - <label for="todo-nom">Nom de la tâche</label> | |
| 11 | + <label for="todo-nom">{{ shell.tr.todo_name_label }}</label> | |
| 12 | 12 | <input class="input" id="todo-nom" name="nom" value="{{ name }}" required |
| 13 | - placeholder="Nettoyer la salle de bain" style="min-height:46px"> | |
| 13 | + placeholder="{{ shell.tr.todo_name_ph }}" style="min-height:46px"> | |
| 14 | 14 | </div> |
| 15 | 15 | |
| 16 | 16 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 17 | - <label id="todo-cat-label">Catégorie</label> | |
| 17 | + <label id="todo-cat-label">{{ shell.tr.category_label }}</label> | |
| 18 | 18 | </div> |
| 19 | 19 | <div class="chips" role="radiogroup" aria-labelledby="todo-cat-label" style="margin-bottom:var(--space-6)"> |
| 20 | 20 | {% for c in cats %} |
| @@ -26,14 +26,14 @@ | ||
| 26 | 26 | </div> |
| 27 | 27 | |
| 28 | 28 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 29 | - <label for="todo-date">Date d'activation</label> | |
| 29 | + <label for="todo-date">{{ shell.tr.activation_date }}</label> | |
| 30 | 30 | <div style="display:flex;gap:var(--space-2);align-items:center"> |
| 31 | - <button type="button" class="btn btn-secondary btn-icon" data-day="-1" aria-label="Jour précédent" hidden> | |
| 31 | + <button type="button" class="btn btn-secondary btn-icon" data-day="-1" aria-label="{{ shell.tr.day_prev }}" hidden> | |
| 32 | 32 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> |
| 33 | 33 | </button> |
| 34 | 34 | <input class="input" id="todo-date" name="date" type="date" value="{{ date }}" required |
| 35 | 35 | style="flex:1;min-height:46px;text-align:center"> |
| 36 | - <button type="button" class="btn btn-secondary btn-icon" data-day="1" aria-label="Jour suivant" hidden> | |
| 36 | + <button type="button" class="btn btn-secondary btn-icon" data-day="1" aria-label="{{ shell.tr.day_next }}" hidden> | |
| 37 | 37 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> |
| 38 | 38 | </button> |
| 39 | 39 | </div> |
| @@ -41,7 +41,7 @@ | ||
| 41 | 41 | <p id="todo-date-label" style="font-size:13px;opacity:.7;margin:0 0 var(--space-6)">{{ date_label }}</p> |
| 42 | 42 | |
| 43 | 43 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 44 | - <label id="todo-asg-label">Assigner à</label> | |
| 44 | + <label id="todo-asg-label">{{ shell.tr.assign_to }}</label> | |
| 45 | 45 | </div> |
| 46 | 46 | <div class="chips" role="group" aria-labelledby="todo-asg-label" style="margin-bottom:var(--space-2)"> |
| 47 | 47 | {% for m in members %} |
| @@ -51,17 +51,17 @@ | ||
| 51 | 51 | </label> |
| 52 | 52 | {% endfor %} |
| 53 | 53 | </div> |
| 54 | - <p style="font-size:12px;opacity:.7;margin:0 0 var(--space-8)">Facultatif. Les autres membres peuvent quand même prendre la tâche.</p> | |
| 54 | + <p style="font-size:12px;opacity:.7;margin:0 0 var(--space-8)">{{ shell.tr.assign_note }}</p> | |
| 55 | 55 | |
| 56 | 56 | <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px"> |
| 57 | - {% if edit_id.is_some() %}Enregistrer{% else %}Créer la tâche{% endif %} | |
| 57 | + {% if edit_id.is_some() %}{{ shell.tr.save }}{% else %}{{ shell.tr.create_todo }}{% endif %} | |
| 58 | 58 | </button> |
| 59 | 59 | </form> |
| 60 | 60 | |
| 61 | 61 | {% if let Some(id) = edit_id %} |
| 62 | 62 | <form method="post" action="/taches/{{ id }}/supprimer" style="margin-top:var(--space-3)"> |
| 63 | 63 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 64 | - <button type="submit" class="btn btn-secondary btn-block" style="min-height:48px">Supprimer la tâche</button> | |
| 64 | + <button type="submit" class="btn btn-secondary btn-block" style="min-height:48px">{{ shell.tr.delete_todo }}</button> | |
| 65 | 65 | </form> |
| 66 | 66 | {% endif %} |
| 67 | 67 | |
| @@ -1,6 +1,6 @@ | |||
| 1 | {% extends "app.html" %} | 1 | {% extends "app.html" %} |
| 2 | {% block content %} | 2 | {% block content %} |
| 3 | -<a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0" href="/taches">← Tâches</a> | 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0" href="/taches">{{ shell.tr.back_todos }}</a> |
| 4 | 4 | ||
| 5 | {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %} | 5 | {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %} |
| 6 | 6 | ||
| @@ -8,13 +8,13 @@ | |||
| 8 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 8 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 9 | 9 | ||
| 10 | <div class="field" style="margin-bottom:var(--space-4)"> | 10 | <div class="field" style="margin-bottom:var(--space-4)"> |
| 11 | - <label for="todo-nom">Nom de la tâche</label> | 11 | + <label for="todo-nom">{{ shell.tr.todo_name_label }}</label> |
| 12 | <input class="input" id="todo-nom" name="nom" value="{{ name }}" required | 12 | <input class="input" id="todo-nom" name="nom" value="{{ name }}" required |
| 13 | - placeholder="Nettoyer la salle de bain" style="min-height:46px"> | 13 | + placeholder="{{ shell.tr.todo_name_ph }}" style="min-height:46px"> |
| 14 | </div> | 14 | </div> |
| 15 | 15 | ||
| 16 | <div class="field" style="margin-bottom:var(--space-2)"> | 16 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 17 | - <label id="todo-cat-label">Catégorie</label> | 17 | + <label id="todo-cat-label">{{ shell.tr.category_label }}</label> |
| 18 | </div> | 18 | </div> |
| 19 | <div class="chips" role="radiogroup" aria-labelledby="todo-cat-label" style="margin-bottom:var(--space-6)"> | 19 | <div class="chips" role="radiogroup" aria-labelledby="todo-cat-label" style="margin-bottom:var(--space-6)"> |
| 20 | {% for c in cats %} | 20 | {% for c in cats %} |
| @@ -26,14 +26,14 @@ | |||
| 26 | </div> | 26 | </div> |
| 27 | 27 | ||
| 28 | <div class="field" style="margin-bottom:var(--space-2)"> | 28 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 29 | - <label for="todo-date">Date d'activation</label> | 29 | + <label for="todo-date">{{ shell.tr.activation_date }}</label> |
| 30 | <div style="display:flex;gap:var(--space-2);align-items:center"> | 30 | <div style="display:flex;gap:var(--space-2);align-items:center"> |
| 31 | - <button type="button" class="btn btn-secondary btn-icon" data-day="-1" aria-label="Jour précédent" hidden> | 31 | + <button type="button" class="btn btn-secondary btn-icon" data-day="-1" aria-label="{{ shell.tr.day_prev }}" hidden> |
| 32 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> | 32 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> |
| 33 | </button> | 33 | </button> |
| 34 | <input class="input" id="todo-date" name="date" type="date" value="{{ date }}" required | 34 | <input class="input" id="todo-date" name="date" type="date" value="{{ date }}" required |
| 35 | style="flex:1;min-height:46px;text-align:center"> | 35 | style="flex:1;min-height:46px;text-align:center"> |
| 36 | - <button type="button" class="btn btn-secondary btn-icon" data-day="1" aria-label="Jour suivant" hidden> | 36 | + <button type="button" class="btn btn-secondary btn-icon" data-day="1" aria-label="{{ shell.tr.day_next }}" hidden> |
| 37 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | 37 | <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> |
| 38 | </button> | 38 | </button> |
| 39 | </div> | 39 | </div> |
| @@ -41,7 +41,7 @@ | |||
| 41 | <p id="todo-date-label" style="font-size:13px;opacity:.7;margin:0 0 var(--space-6)">{{ date_label }}</p> | 41 | <p id="todo-date-label" style="font-size:13px;opacity:.7;margin:0 0 var(--space-6)">{{ date_label }}</p> |
| 42 | 42 | ||
| 43 | <div class="field" style="margin-bottom:var(--space-2)"> | 43 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 44 | - <label id="todo-asg-label">Assigner à</label> | 44 | + <label id="todo-asg-label">{{ shell.tr.assign_to }}</label> |
| 45 | </div> | 45 | </div> |
| 46 | <div class="chips" role="group" aria-labelledby="todo-asg-label" style="margin-bottom:var(--space-2)"> | 46 | <div class="chips" role="group" aria-labelledby="todo-asg-label" style="margin-bottom:var(--space-2)"> |
| 47 | {% for m in members %} | 47 | {% for m in members %} |
| @@ -51,17 +51,17 @@ | |||
| 51 | </label> | 51 | </label> |
| 52 | {% endfor %} | 52 | {% endfor %} |
| 53 | </div> | 53 | </div> |
| 54 | - <p style="font-size:12px;opacity:.7;margin:0 0 var(--space-8)">Facultatif. Les autres membres peuvent quand même prendre la tâche.</p> | 54 | + <p style="font-size:12px;opacity:.7;margin:0 0 var(--space-8)">{{ shell.tr.assign_note }}</p> |
| 55 | 55 | ||
| 56 | <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px"> | 56 | <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px"> |
| 57 | - {% if edit_id.is_some() %}Enregistrer{% else %}Créer la tâche{% endif %} | 57 | + {% if edit_id.is_some() %}{{ shell.tr.save }}{% else %}{{ shell.tr.create_todo }}{% endif %} |
| 58 | </button> | 58 | </button> |
| 59 | </form> | 59 | </form> |
| 60 | 60 | ||
| 61 | {% if let Some(id) = edit_id %} | 61 | {% if let Some(id) = edit_id %} |
| 62 | <form method="post" action="/taches/{{ id }}/supprimer" style="margin-top:var(--space-3)"> | 62 | <form method="post" action="/taches/{{ id }}/supprimer" style="margin-top:var(--space-3)"> |
| 63 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 63 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 64 | - <button type="submit" class="btn btn-secondary btn-block" style="min-height:48px">Supprimer la tâche</button> | 64 | + <button type="submit" class="btn btn-secondary btn-block" style="min-height:48px">{{ shell.tr.delete_todo }}</button> |
| 65 | </form> | 65 | </form> |
| 66 | {% endif %} | 66 | {% endif %} |
| 67 | 67 | ||
modified
templates/todos.html +20 -20 | @@ -1,26 +1,26 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 2 | {% block content %} |
| 3 | 3 | <div class="todo-toolbar"> |
| 4 | - <a class="btn btn-primary" style="min-height:40px" href="/taches/nouvelle">+ Nouvelle tâche</a> | |
| 4 | + <a class="btn btn-primary" style="min-height:40px" href="/taches/nouvelle">{{ shell.tr.new_todo }}</a> | |
| 5 | 5 | </div> |
| 6 | 6 | |
| 7 | 7 | {% if empty %} |
| 8 | 8 | <div class="todo-empty"> |
| 9 | - <p class="te-title">Aucune tâche pour l'instant</p> | |
| 10 | - <p class="te-body">Ajoute ce qu'il y a à faire dans {{ shell.team_name }} : chacun pourra la prendre, la faire, et le temps passé ira dans les stats.</p> | |
| 9 | + <p class="te-title">{{ shell.tr.todos_empty_title }}</p> | |
| 10 | + <p class="te-body">{{ empty_body }}</p> | |
| 11 | 11 | </div> |
| 12 | 12 | {% endif %} |
| 13 | 13 | |
| 14 | 14 | {% if has_doing %} |
| 15 | 15 | <div class="blk-head"> |
| 16 | - <h2>En cours</h2> | |
| 16 | + <h2>{{ shell.tr.doing_block }}</h2> | |
| 17 | 17 | <a class="blk-toggle" role="switch" aria-checked="{{ doing_filter_on }}" href="{{ doing_toggle_href }}"> |
| 18 | - Mes tâches | |
| 18 | + {{ shell.tr.my_tasks }} | |
| 19 | 19 | <span class="swt{% if doing_filter_on %} on{% endif %}"><span class="knob"></span></span> |
| 20 | 20 | </a> |
| 21 | 21 | </div> |
| 22 | 22 | {% if doing.is_empty() %} |
| 23 | -<p class="blk-empty">Aucune tâche en cours ne t'est assignée.</p> | |
| 23 | +<p class="blk-empty">{{ shell.tr.doing_filter_empty }}</p> | |
| 24 | 24 | {% else %} |
| 25 | 25 | <div class="todo-grid"> |
| 26 | 26 | {% for t in doing %} |
| @@ -37,17 +37,17 @@ | ||
| 37 | 37 | {% endif %} |
| 38 | 38 | </div> |
| 39 | 39 | {% if t.mine %} |
| 40 | - <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="Modifier la tâche {{ t.name }}"> | |
| 40 | + <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="{{ t.edit_aria }}"> | |
| 41 | 41 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> |
| 42 | 42 | </a> |
| 43 | 43 | {% endif %} |
| 44 | 44 | </div> |
| 45 | 45 | {% if t.mine %} |
| 46 | 46 | <div class="todo-actions"> |
| 47 | - <a class="btn btn-primary" href="/taches/{{ t.id }}/terminer">Fait ✓</a> | |
| 47 | + <a class="btn btn-primary" href="/taches/{{ t.id }}/terminer">{{ shell.tr.done_check }}</a> | |
| 48 | 48 | <form method="post" action="/taches/{{ t.id }}/relacher"> |
| 49 | 49 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 50 | - <button type="submit" class="btn btn-secondary" style="flex:0 1 auto">Relâcher</button> | |
| 50 | + <button type="submit" class="btn btn-secondary" style="flex:0 1 auto">{{ shell.tr.release }}</button> | |
| 51 | 51 | </form> |
| 52 | 52 | </div> |
| 53 | 53 | {% endif %} |
| @@ -59,14 +59,14 @@ | ||
| 59 | 59 | |
| 60 | 60 | {% if has_open %} |
| 61 | 61 | <div class="blk-head"> |
| 62 | - <h2>À faire</h2> | |
| 62 | + <h2>{{ shell.tr.todo_block }}</h2> | |
| 63 | 63 | <a class="blk-toggle" role="switch" aria-checked="{{ open_filter_on }}" href="{{ open_toggle_href }}"> |
| 64 | - Mes tâches | |
| 64 | + {{ shell.tr.my_tasks }} | |
| 65 | 65 | <span class="swt{% if open_filter_on %} on{% endif %}"><span class="knob"></span></span> |
| 66 | 66 | </a> |
| 67 | 67 | </div> |
| 68 | 68 | {% if open.is_empty() %} |
| 69 | -<p class="blk-empty">Aucune tâche à faire ne t'est assignée.</p> | |
| 69 | +<p class="blk-empty">{{ shell.tr.open_filter_empty }}</p> | |
| 70 | 70 | {% else %} |
| 71 | 71 | <div class="todo-grid"> |
| 72 | 72 | {% for t in open %} |
| @@ -82,16 +82,16 @@ | ||
| 82 | 82 | </div> |
| 83 | 83 | {% endif %} |
| 84 | 84 | </div> |
| 85 | - <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="Modifier la tâche {{ t.name }}"> | |
| 85 | + <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="{{ t.edit_aria }}"> | |
| 86 | 86 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> |
| 87 | 87 | </a> |
| 88 | 88 | </div> |
| 89 | 89 | <div class="todo-actions"> |
| 90 | 90 | <form method="post" action="/taches/{{ t.id }}/prendre"> |
| 91 | 91 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 92 | - <button type="submit" class="btn btn-secondary">Prendre</button> | |
| 92 | + <button type="submit" class="btn btn-secondary">{{ shell.tr.take }}</button> | |
| 93 | 93 | </form> |
| 94 | - <a class="btn btn-primary" href="/taches/{{ t.id }}/terminer">Fait ✓</a> | |
| 94 | + <a class="btn btn-primary" href="/taches/{{ t.id }}/terminer">{{ shell.tr.done_check }}</a> | |
| 95 | 95 | </div> |
| 96 | 96 | </div> |
| 97 | 97 | {% endfor %} |
| @@ -101,7 +101,7 @@ | ||
| 101 | 101 | |
| 102 | 102 | {% if !later.is_empty() %} |
| 103 | 103 | <details class="later"> |
| 104 | - <summary>À venir · {{ later.len() }}</summary> | |
| 104 | + <summary>{{ shell.tr.upcoming }} · {{ later.len() }}</summary> | |
| 105 | 105 | <div style="margin-top:var(--space-3)"> |
| 106 | 106 | {% for t in later %} |
| 107 | 107 | <div class="later-row"> |
| @@ -115,7 +115,7 @@ | ||
| 115 | 115 | </div> |
| 116 | 116 | {% endif %} |
| 117 | 117 | </div> |
| 118 | - <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="Modifier la tâche {{ t.name }}"> | |
| 118 | + <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="{{ t.edit_aria }}"> | |
| 119 | 119 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> |
| 120 | 120 | </a> |
| 121 | 121 | </div> |
| @@ -141,11 +141,11 @@ | ||
| 141 | 141 | var wrap = document.createElement('div'); |
| 142 | 142 | wrap.className = 'sheet-wrap open'; |
| 143 | 143 | wrap.setAttribute('role', 'dialog'); |
| 144 | - wrap.setAttribute('aria-label', 'Tâche effectuée'); | |
| 144 | + wrap.setAttribute('aria-label', '{{ shell.tr.title_todo_done }}'); | |
| 145 | 145 | wrap.innerHTML = '<span class="sheet-backdrop"></span>' + |
| 146 | 146 | '<div class="sheet sheet-scroll"><div class="sheet-head">' + |
| 147 | - '<span class="pname">Tâche effectuée</span>' + | |
| 148 | - '<button type="button" class="btn btn-ghost close">Fermer</button></div></div>'; | |
| 147 | + '<span class="pname">{{ shell.tr.title_todo_done }}</span>' + | |
| 148 | + '<button type="button" class="btn btn-ghost close">{{ shell.tr.close }}</button></div></div>'; | |
| 149 | 149 | var sheet = wrap.querySelector('.sheet'); |
| 150 | 150 | if (title) sheet.appendChild(title); |
| 151 | 151 | sheet.appendChild(form); |
| @@ -1,26 +1,26 @@ | |||
| 1 | {% extends "app.html" %} | 1 | {% extends "app.html" %} |
| 2 | {% block content %} | 2 | {% block content %} |
| 3 | <div class="todo-toolbar"> | 3 | <div class="todo-toolbar"> |
| 4 | - <a class="btn btn-primary" style="min-height:40px" href="/taches/nouvelle">+ Nouvelle tâche</a> | 4 | + <a class="btn btn-primary" style="min-height:40px" href="/taches/nouvelle">{{ shell.tr.new_todo }}</a> |
| 5 | </div> | 5 | </div> |
| 6 | 6 | ||
| 7 | {% if empty %} | 7 | {% if empty %} |
| 8 | <div class="todo-empty"> | 8 | <div class="todo-empty"> |
| 9 | - <p class="te-title">Aucune tâche pour l'instant</p> | 9 | + <p class="te-title">{{ shell.tr.todos_empty_title }}</p> |
| 10 | - <p class="te-body">Ajoute ce qu'il y a à faire dans {{ shell.team_name }} : chacun pourra la prendre, la faire, et le temps passé ira dans les stats.</p> | 10 | + <p class="te-body">{{ empty_body }}</p> |
| 11 | </div> | 11 | </div> |
| 12 | {% endif %} | 12 | {% endif %} |
| 13 | 13 | ||
| 14 | {% if has_doing %} | 14 | {% if has_doing %} |
| 15 | <div class="blk-head"> | 15 | <div class="blk-head"> |
| 16 | - <h2>En cours</h2> | 16 | + <h2>{{ shell.tr.doing_block }}</h2> |
| 17 | <a class="blk-toggle" role="switch" aria-checked="{{ doing_filter_on }}" href="{{ doing_toggle_href }}"> | 17 | <a class="blk-toggle" role="switch" aria-checked="{{ doing_filter_on }}" href="{{ doing_toggle_href }}"> |
| 18 | - Mes tâches | 18 | + {{ shell.tr.my_tasks }} |
| 19 | <span class="swt{% if doing_filter_on %} on{% endif %}"><span class="knob"></span></span> | 19 | <span class="swt{% if doing_filter_on %} on{% endif %}"><span class="knob"></span></span> |
| 20 | </a> | 20 | </a> |
| 21 | </div> | 21 | </div> |
| 22 | {% if doing.is_empty() %} | 22 | {% if doing.is_empty() %} |
| 23 | -<p class="blk-empty">Aucune tâche en cours ne t'est assignée.</p> | 23 | +<p class="blk-empty">{{ shell.tr.doing_filter_empty }}</p> |
| 24 | {% else %} | 24 | {% else %} |
| 25 | <div class="todo-grid"> | 25 | <div class="todo-grid"> |
| 26 | {% for t in doing %} | 26 | {% for t in doing %} |
| @@ -37,17 +37,17 @@ | |||
| 37 | {% endif %} | 37 | {% endif %} |
| 38 | </div> | 38 | </div> |
| 39 | {% if t.mine %} | 39 | {% if t.mine %} |
| 40 | - <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="Modifier la tâche {{ t.name }}"> | 40 | + <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="{{ t.edit_aria }}"> |
| 41 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> | 41 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> |
| 42 | </a> | 42 | </a> |
| 43 | {% endif %} | 43 | {% endif %} |
| 44 | </div> | 44 | </div> |
| 45 | {% if t.mine %} | 45 | {% if t.mine %} |
| 46 | <div class="todo-actions"> | 46 | <div class="todo-actions"> |
| 47 | - <a class="btn btn-primary" href="/taches/{{ t.id }}/terminer">Fait ✓</a> | 47 | + <a class="btn btn-primary" href="/taches/{{ t.id }}/terminer">{{ shell.tr.done_check }}</a> |
| 48 | <form method="post" action="/taches/{{ t.id }}/relacher"> | 48 | <form method="post" action="/taches/{{ t.id }}/relacher"> |
| 49 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 49 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 50 | - <button type="submit" class="btn btn-secondary" style="flex:0 1 auto">Relâcher</button> | 50 | + <button type="submit" class="btn btn-secondary" style="flex:0 1 auto">{{ shell.tr.release }}</button> |
| 51 | </form> | 51 | </form> |
| 52 | </div> | 52 | </div> |
| 53 | {% endif %} | 53 | {% endif %} |
| @@ -59,14 +59,14 @@ | |||
| 59 | 59 | ||
| 60 | {% if has_open %} | 60 | {% if has_open %} |
| 61 | <div class="blk-head"> | 61 | <div class="blk-head"> |
| 62 | - <h2>À faire</h2> | 62 | + <h2>{{ shell.tr.todo_block }}</h2> |
| 63 | <a class="blk-toggle" role="switch" aria-checked="{{ open_filter_on }}" href="{{ open_toggle_href }}"> | 63 | <a class="blk-toggle" role="switch" aria-checked="{{ open_filter_on }}" href="{{ open_toggle_href }}"> |
| 64 | - Mes tâches | 64 | + {{ shell.tr.my_tasks }} |
| 65 | <span class="swt{% if open_filter_on %} on{% endif %}"><span class="knob"></span></span> | 65 | <span class="swt{% if open_filter_on %} on{% endif %}"><span class="knob"></span></span> |
| 66 | </a> | 66 | </a> |
| 67 | </div> | 67 | </div> |
| 68 | {% if open.is_empty() %} | 68 | {% if open.is_empty() %} |
| 69 | -<p class="blk-empty">Aucune tâche à faire ne t'est assignée.</p> | 69 | +<p class="blk-empty">{{ shell.tr.open_filter_empty }}</p> |
| 70 | {% else %} | 70 | {% else %} |
| 71 | <div class="todo-grid"> | 71 | <div class="todo-grid"> |
| 72 | {% for t in open %} | 72 | {% for t in open %} |
| @@ -82,16 +82,16 @@ | |||
| 82 | </div> | 82 | </div> |
| 83 | {% endif %} | 83 | {% endif %} |
| 84 | </div> | 84 | </div> |
| 85 | - <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="Modifier la tâche {{ t.name }}"> | 85 | + <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="{{ t.edit_aria }}"> |
| 86 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> | 86 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> |
| 87 | </a> | 87 | </a> |
| 88 | </div> | 88 | </div> |
| 89 | <div class="todo-actions"> | 89 | <div class="todo-actions"> |
| 90 | <form method="post" action="/taches/{{ t.id }}/prendre"> | 90 | <form method="post" action="/taches/{{ t.id }}/prendre"> |
| 91 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 91 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 92 | - <button type="submit" class="btn btn-secondary">Prendre</button> | 92 | + <button type="submit" class="btn btn-secondary">{{ shell.tr.take }}</button> |
| 93 | </form> | 93 | </form> |
| 94 | - <a class="btn btn-primary" href="/taches/{{ t.id }}/terminer">Fait ✓</a> | 94 | + <a class="btn btn-primary" href="/taches/{{ t.id }}/terminer">{{ shell.tr.done_check }}</a> |
| 95 | </div> | 95 | </div> |
| 96 | </div> | 96 | </div> |
| 97 | {% endfor %} | 97 | {% endfor %} |
| @@ -101,7 +101,7 @@ | |||
| 101 | 101 | ||
| 102 | {% if !later.is_empty() %} | 102 | {% if !later.is_empty() %} |
| 103 | <details class="later"> | 103 | <details class="later"> |
| 104 | - <summary>À venir · {{ later.len() }}</summary> | 104 | + <summary>{{ shell.tr.upcoming }} · {{ later.len() }}</summary> |
| 105 | <div style="margin-top:var(--space-3)"> | 105 | <div style="margin-top:var(--space-3)"> |
| 106 | {% for t in later %} | 106 | {% for t in later %} |
| 107 | <div class="later-row"> | 107 | <div class="later-row"> |
| @@ -115,7 +115,7 @@ | |||
| 115 | </div> | 115 | </div> |
| 116 | {% endif %} | 116 | {% endif %} |
| 117 | </div> | 117 | </div> |
| 118 | - <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="Modifier la tâche {{ t.name }}"> | 118 | + <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="{{ t.edit_aria }}"> |
| 119 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> | 119 | <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> |
| 120 | </a> | 120 | </a> |
| 121 | </div> | 121 | </div> |
| @@ -141,11 +141,11 @@ | |||
| 141 | var wrap = document.createElement('div'); | 141 | var wrap = document.createElement('div'); |
| 142 | wrap.className = 'sheet-wrap open'; | 142 | wrap.className = 'sheet-wrap open'; |
| 143 | wrap.setAttribute('role', 'dialog'); | 143 | wrap.setAttribute('role', 'dialog'); |
| 144 | - wrap.setAttribute('aria-label', 'Tâche effectuée'); | 144 | + wrap.setAttribute('aria-label', '{{ shell.tr.title_todo_done }}'); |
| 145 | wrap.innerHTML = '<span class="sheet-backdrop"></span>' + | 145 | wrap.innerHTML = '<span class="sheet-backdrop"></span>' + |
| 146 | '<div class="sheet sheet-scroll"><div class="sheet-head">' + | 146 | '<div class="sheet sheet-scroll"><div class="sheet-head">' + |
| 147 | - '<span class="pname">Tâche effectuée</span>' + | 147 | + '<span class="pname">{{ shell.tr.title_todo_done }}</span>' + |
| 148 | - '<button type="button" class="btn btn-ghost close">Fermer</button></div></div>'; | 148 | + '<button type="button" class="btn btn-ghost close">{{ shell.tr.close }}</button></div></div>'; |
| 149 | var sheet = wrap.querySelector('.sheet'); | 149 | var sheet = wrap.querySelector('.sheet'); |
| 150 | if (title) sheet.appendChild(title); | 150 | if (title) sheet.appendChild(title); |
| 151 | sheet.appendChild(form); | 151 | sheet.appendChild(form); |