judu/ex-egopublic Fork
⑂ 8755846
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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
Julien Durillon committed 2026-08-09T00:11:59+02:00 Browse files
8755846 parent: ecdbd69
modified README.md +8 -2
@@ -1,7 +1,8 @@
11 # time-time
22
33 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
56 JavaScript n'existe qu'en amélioration progressive : tout fonctionne par liens
67 et formulaires.
78
@@ -29,6 +30,10 @@ front, une seule feuille de CSS.
2930 révocation au logout, CSRF stateless, argon2id.
3031 - **Accessibilité** : WCAG 2.1/2.2 AA — zéro violation axe sur les 12
3132 é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`.
3237
3338 ## Captures d'écran
3439
@@ -66,6 +71,7 @@ Variables d'environnement du serveur :
6671 | `POSTGRESQL_ADDON_URI` ou `DATABASE_URL` | connexion PostgreSQL (TLS imposé côté client quoi que dise l'URL) |
6772 | `BISCUIT_PRIVATE_KEY` | clé privée racine (hex, via `keygen`) |
6873 | `PORT` | port d'écoute (défaut 8080) |
74+| `DEFAULT_LANG` | langue des nouveaux comptes et des pages anonymes : `fr` (défaut), `en` ou `de` |
6975
7076 ### Développement local
7177
@@ -111,7 +117,7 @@ cargo run -- user add alice --team "Maison"
111117 Notes :
112118
113119 - 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.
115121 - Dimensionnement éprouvé : build flavor `L`, run flavor `nano`
116122 (`clever scale --build-flavor L --flavor nano`).
117123 - Le plan PostgreSQL DEV est limité à 5 connexions : le pool de l'app est
@@ -1,7 +1,8 @@
1 # time-time1 # time-time
2 2
3 Application web de comptage de temps par catégorie et de tâches partagées, en3 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 — le4+é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 liens6 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 1231 - **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'écran38 ## 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 local76 ### 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` contient119 - 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 est123 - 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); }
537537 .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; }
538538 .done-sec .done-body { transition: opacity .15s ease; }
539539 .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+}
540551 /* Carte-lien « Mes saisies » (Réglages). */
541552 a.times-link { flex-direction: row; align-items: center; text-decoration: none; color: var(--color-text); }
542553
@@ -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;
44
55 use crate::db::stats::StatRow;
66 use crate::db::users::UserRow;
7+use crate::i18n::Tr;
78
89 /// Palette du design system « Organic » — couleur par ordre de création de la
910 /// tâche (les UUIDv7 trient dans l'ordre de création), stable dans le temps.
@@ -99,6 +100,7 @@ pub fn build_chart(
99100 rows: &[StatRow],
100101 colors: &HashMap<Uuid, &'static str>,
101102 nav: Option<ChartNav>,
103+ tr: &Tr,
102104 ) -> (ChartView, Vec<Sheet>) {
103105 let mut by_user: BTreeMap<Uuid, Vec<&StatRow>> = BTreeMap::new();
104106 for r in rows {
@@ -140,11 +142,7 @@ pub fn build_chart(
140142 duration: fmt_minutes(r.total),
141143 share: format!("{share} %"),
142144 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),
148146 });
149147 segs.push(Seg {
150148 flex: r.total,
@@ -236,7 +234,7 @@ mod tests {
236234 row(1, 11, "réunions", 30, 1),
237235 row(2, 10, "dev", 240, 3),
238236 ];
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);
240238 assert_eq!(chart.rows.len(), 2);
241239 assert_eq!(chart.rows[1].height_pct, 100); // bob = max
242240 assert_eq!(chart.rows[0].height_pct, 50); // alice = 120/240
@@ -253,7 +251,7 @@ mod tests {
253251 fn zero_user_min_height() {
254252 let users = [user(1, "alice"), user(2, "bob")];
255253 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);
257255 assert!(chart.rows[1].segs.is_empty());
258256 assert_eq!(chart.rows[1].height_pct, 2);
259257 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 la9 /// 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 = max239 assert_eq!(chart.rows[1].height_pct, 100); // bob = max
242 assert_eq!(chart.rows[0].height_pct, 50); // alice = 120/240240 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)
7575 let hash = password::hash(&password)?;
7676 let id = Uuid::now_v7();
7777 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)")
7979 .bind(id)
8080 .bind(username)
8181 .bind(&hash)
82+ .bind(config::default_lang().code())
8283 .execute(&mut *tx)
8384 .await;
8485 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 {
2525 .unwrap_or(8080)
2626 }
2727
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+
2837 pub fn biscuit_private_key_hex() -> Result<String> {
2938 std::env::var("BISCUIT_PRIVATE_KEY")
3039 .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>> {
4545 Ok(row.map(|(u,)| u))
4646 }
4747
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+
4866 /// Cible d'une invitation.
4967 pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> {
5068 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 {
1919
2020 impl IntoResponse for AppError {
2121 fn into_response(self) -> Response {
22+ let lang = crate::config::default_lang();
23+ let tr = lang.tr();
2224 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()),
2426 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()),
2928 _ => {
3029 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())
3531 }
3632 };
3733 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">
3935 <meta name="viewport" content="width=device-width, initial-scale=1">
4036 <link rel="stylesheet" href="/static/style.css"><title>{status}</title></head>
4137 <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,
4341 status = status.as_u16(),
4442 code = status.as_u16(),
4543 );
@@ -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;
66 mod error;
77 mod period;
88 mod render;
9+mod i18n;
910 mod routes;
1011 mod schedule;
1112 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;
33 use chrono::{Datelike, Duration, NaiveDate, Utc, Weekday};
44 use chrono_tz::Europe::Paris;
55
6+use crate::i18n::{Lang, p1, p2};
7+
68 /// Aujourd'hui en Europe/Paris — seule intrusion de la timezone dans l'appli
79 /// (les entrées sont stockées en DATE).
810 pub fn paris_today() -> NaiveDate {
911 Utc::now().with_timezone(&Paris).date_naive()
1012 }
1113
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-
3114 fn capitalize(s: &str) -> String {
3215 let mut c = s.chars();
3316 match c.next() {
@@ -36,21 +19,23 @@ fn capitalize(s: &str) -> String {
3619 }
3720 }
3821
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+ }
4629 }
4730
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)));
5237 if date == paris_today() {
53- format!("{base} — aujourd'hui")
38+ format!("{base}{}", lang.tr().today_suffix)
5439 } else {
5540 base
5641 }
@@ -103,10 +88,10 @@ impl Week {
10388 }
10489
10590 /// « Semaine du 16 mars au 22 mars »
106- pub fn range_label(&self) -> String {
91+ pub fn range_label(&self, lang: Lang) -> String {
10792 let (start, end) = self.bounds();
10893 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))
11095 }
11196 }
11297
@@ -186,10 +171,10 @@ impl Month {
186171 }
187172
188173 /// « Mars 2026 »
189- pub fn label(&self) -> String {
174+ pub fn label(&self, lang: Lang) -> String {
190175 capitalize(&format!(
191176 "{} {}",
192- MONTH_NAMES[(self.month - 1) as usize],
177+ lang.tr().months[(self.month - 1) as usize],
193178 self.year
194179 ))
195180 }
@@ -239,8 +224,8 @@ impl Year {
239224 }
240225
241226 /// « 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)
244229 }
245230 }
246231
@@ -309,7 +294,8 @@ mod tests {
309294 let y = Year::parse("2026").unwrap();
310295 assert_eq!(y.bounds(), (d(2026, 1, 1), d(2027, 1, 1)));
311296 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");
313299 assert_eq!(y.prev().next(), y);
314300 assert!(Year::parse("abc").is_none());
315301 assert!(Year::parse("12026").is_none());
@@ -317,11 +303,15 @@ mod tests {
317303
318304 #[test]
319305 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");
321308 assert_eq!(
322- Week::parse("2026-W12").unwrap().range_label(),
309+ Week::parse("2026-W12").unwrap().range_label(Lang::Fr),
323310 "Semaine du 16 mars au 22 mars"
324311 );
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");
326316 }
327317 }
@@ -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'appli8 /// 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 base40 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.year178 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 {
4545 pub struct Shell {
4646 pub title: String,
4747 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,
4852 /// Nom de l'équipe active (pill de l'en-tête).
4953 pub team_name: String,
5054 /// 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};
1313 use crate::db::categories::{self, CategoryRow};
1414 use crate::db::timers;
1515 use crate::error::AppError;
16+use crate::i18n::p1;
1617 use crate::period::{Week, day_label, paris_today};
1718 use crate::render::{HtmlTemplate, Shell};
1819 use crate::routes::shell;
@@ -54,8 +55,8 @@ pub struct TimerPage {
5455 pub shell: Shell,
5556 /// « 47 min 23 s » au rendu — le JS prend le relais chaque seconde.
5657 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,
5960 pub started_ms: i64,
6061 }
6162
@@ -111,12 +112,13 @@ pub async fn entry_new(
111112 ctx: ActiveTeam,
112113 Query(q): Query<NewQuery>,
113114 ) -> Result<Response, AppError> {
115+ let tr = ctx.tr();
114116 let toast = if q.annule.is_some() {
115- Some("Temps mesuré annulé".to_string())
117+ Some(tr.toast_measure_discarded.to_string())
116118 } else if q.abandonne.is_some() {
117- Some("Minuteur annulé".to_string())
119+ Some(tr.toast_timer_cancelled.to_string())
118120 } else {
119- q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m)))
121+ q.ok.map(|m| p1(tr.added_pat, fmt_minutes(m)))
120122 };
121123 let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?;
122124 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
@@ -132,13 +134,13 @@ pub async fn entry_new(
132134 .as_ref()
133135 .filter(|t| t.team_id == ctx.team_id && t.stopped_at.is_none())
134136 {
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?;
136138 // L'écran EST le minuteur : pas de bandeau flottant en doublon.
137139 sh.timer = None;
138140 return Ok(HtmlTemplate(TimerPage {
139141 shell: sh,
140142 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()),
142144 started_ms: t.started_at.timestamp_millis(),
143145 })
144146 .into_response());
@@ -178,7 +180,7 @@ pub async fn entry_new(
178180 selected: minutes == m,
179181 })
180182 .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?;
182184 sh.show_start = can_start;
183185 return Ok(HtmlTemplate(AddStep2Page {
184186 csrf: ctx.user.csrf_token(&state),
@@ -193,7 +195,7 @@ pub async fn entry_new(
193195 plus_href: step2_href(picked.id, minutes.saturating_add(5), date),
194196 presets,
195197 date,
196- date_label: day_label(date),
198+ date_label: day_label(ctx.lang, date),
197199 day_prev: step2_href(picked.id, minutes, date - Duration::days(1)),
198200 day_next: (date < paris_today())
199201 .then(|| step2_href(picked.id, minutes, date + Duration::days(1))),
@@ -218,15 +220,15 @@ pub async fn entry_new(
218220 name: t.name.clone(),
219221 week_label: week
220222 .get(&t.id)
221- .map(|m| format!("{} cette semaine", fmt_minutes(*m)))
223+ .map(|m| p1(tr.week_hours_pat, fmt_minutes(*m)))
222224 .unwrap_or_default(),
223225 })
224226 .collect();
225227 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)),
227229 warning: m > 240,
228230 });
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?;
230232 sh.show_start = can_start;
231233 Ok(HtmlTemplate(AddStep1Page {
232234 shell: sh,
@@ -244,11 +246,9 @@ pub struct EntryForm {
244246 csrf: String,
245247 }
246248
247-fn validate(form: &EntryForm) -> Result<(), AppError> {
249+fn validate(form: &EntryForm, tr: &crate::i18n::Tr) -> Result<(), AppError> {
248250 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()));
252252 }
253253 Ok(())
254254 }
@@ -261,7 +261,7 @@ pub async fn entry_create(
261261 if !ctx.user.check_csrf(&state, &form.csrf) {
262262 return Err(AppError::Csrf);
263263 }
264- validate(&form)?;
264+ validate(&form, ctx.tr())?;
265265 let n = entries::insert(
266266 &state.pool,
267267 ctx.user.id,
@@ -273,7 +273,7 @@ pub async fn entry_create(
273273 .await?;
274274 if n == 0 {
275275 return Err(AppError::BadRequest(
276- "Cette catégorie n'appartient pas à l'équipe active.".into(),
276+ ctx.tr().err_cat_team.into(),
277277 ));
278278 }
279279 // Une mesure de timer en attente est consommée par l'enregistrement.
@@ -310,9 +310,10 @@ pub async fn entries_list(
310310 ctx: ActiveTeam,
311311 Query(q): Query<PageQuery>,
312312 ) -> Result<HtmlTemplate<TimesPage>, AppError> {
313+ let tr = ctx.tr();
313314 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()),
316317 _ => None,
317318 };
318319 let page = q.page.unwrap_or(1).max(1);
@@ -327,13 +328,13 @@ pub async fn entries_list(
327328 .map(|e: EntryRow| TimeView {
328329 color: colors.get(&e.category_id).copied().unwrap_or("#a19786"),
329330 category: e.category_name,
330- date: day_label(e.entry_date),
331+ date: day_label(ctx.lang, e.entry_date),
331332 duration: fmt_minutes(e.minutes as i64),
332333 edit_href: format!("/temps/{}/modifier", e.id),
333334 })
334335 .collect();
335336 Ok(HtmlTemplate(TimesPage {
336- shell: shell(&state, &ctx, "Mes temps", "times", toast).await?,
337+ shell: shell(&state, &ctx, tr.title_times, "times", toast).await?,
337338 entries,
338339 more_href: has_more.then(|| format!("/temps?page={}", page + 1)),
339340 }))
@@ -375,7 +376,7 @@ pub async fn entry_edit(
375376 .map(|t| CategoryOption {
376377 selected: t.id == entry.category_id,
377378 name: if t.archived {
378- format!("{} (archivée)", t.name)
379+ p1(ctx.tr().archived_suffix_pat, &t.name)
379380 } else {
380381 t.name
381382 },
@@ -385,7 +386,7 @@ pub async fn entry_edit(
385386 options.sort_by(|a, b| a.name.cmp(&b.name));
386387 Ok(HtmlTemplate(EditPage {
387388 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?,
389390 action: format!("/temps/{id}"),
390391 delete_action: format!("/temps/{id}/supprimer"),
391392 categories: options,
@@ -403,7 +404,7 @@ pub async fn entry_update(
403404 if !ctx.user.check_csrf(&state, &form.csrf) {
404405 return Err(AppError::Csrf);
405406 }
406- validate(&form)?;
407+ validate(&form, ctx.tr())?;
407408 let n = entries::update_owned(
408409 &state.pool,
409410 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: week221 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.name381 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;
1717 #[template(path = "login.html")]
1818 pub struct LoginPage {
1919 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,
2023 }
2124
2225 #[derive(Deserialize)]
@@ -29,8 +32,11 @@ pub async fn login_page(user: Option<AuthUser>, Query(q): Query<LoginQuery>) ->
2932 if user.is_some() {
3033 return Redirect::to("/").into_response();
3134 }
35+ let lang = crate::config::default_lang();
3236 HtmlTemplate(LoginPage {
3337 error: q.err.is_some(),
38+ tr: lang.tr(),
39+ lang_code: lang.code(),
3440 })
3541 .into_response()
3642 }
@@ -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};
1616
1717 use crate::auth::extractor::AuthUser;
1818 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};
2020 use crate::error::AppError;
2121 use crate::render::{Shell, TeamChoiceView, TimerView};
2222 use crate::state::AppState;
@@ -47,6 +47,7 @@ pub fn router(state: AppState) -> Router {
4747 "/parametres/mot-de-passe",
4848 post(settings::password_change),
4949 )
50+ .route("/parametres/langue", post(settings::lang_change))
5051 .route("/taches", get(todos::todos_page).post(todos::todo_create))
5152 .route("/taches/nouvelle", get(todos::todo_new))
5253 .route("/taches/{id}/modifier", get(todos::todo_edit))
@@ -130,12 +131,10 @@ pub async fn shell(
130131 tab: &'static str,
131132 toast: Option<String>,
132133 ) -> Result<Shell, AppError> {
133- let username = users::username(&state.pool, ctx.user.id)
134- .await?
135- .unwrap_or_default();
136134 let invite_count = invitations::count_for(&state.pool, ctx.user.id).await?;
137135 let todo_count =
138136 todos_db::open_count(&state.pool, ctx.team_id, crate::period::paris_today()).await?;
137+ let tr = ctx.tr();
139138 let timer_row = timers::get(&state.pool, ctx.user.id).await?;
140139 let badge = timer_row.is_some();
141140 let timer = timer_row.map(|t| {
@@ -144,9 +143,9 @@ pub async fn shell(
144143 Some(m) => TimerView {
145144 running: false,
146145 foreign,
147- label: format!("{} mesurées", fmt_minutes(m)),
146+ label: crate::i18n::p1(tr.measured_pat, fmt_minutes(m)),
148147 note: if foreign {
149- format!("dans l’équipe {}", t.team_name)
148+ crate::i18n::p1(tr.in_team_pat, &t.team_name)
150149 } else {
151150 String::new()
152151 },
@@ -157,9 +156,9 @@ pub async fn shell(
157156 foreign,
158157 label: fmt_minutes(t.elapsed_minutes(chrono::Utc::now())),
159158 note: if foreign {
160- format!("timer dans l’équipe {}", t.team_name)
159+ crate::i18n::p1(tr.timer_in_team_pat, &t.team_name)
161160 } else {
162- "en cours".to_string()
161+ tr.timer_running_note.to_string()
163162 },
164163 started_ms: t.started_at.timestamp_millis(),
165164 },
@@ -167,7 +166,9 @@ pub async fn shell(
167166 });
168167 Ok(Shell {
169168 title: title.to_string(),
170- username,
169+ username: ctx.username.clone(),
170+ lang_code: ctx.lang.code(),
171+ tr,
171172 team_name: ctx.team_name.clone(),
172173 team_color: team_color(ctx.team_id),
173174 tab,
@@ -191,7 +192,7 @@ pub async fn shell(
191192 mine.into_iter()
192193 .map(|t| TeamChoiceView {
193194 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),
195196 color: team_color(t.id),
196197 id: t.id,
197198 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;
99 use crate::charts::color_map;
1010 use crate::db::{invitations, categories, teams, users};
1111 use crate::error::AppError;
12+use crate::i18n::{p1, p2};
1213 use crate::render::{HtmlTemplate, Shell};
1314 use crate::routes::shell;
1415 use crate::state::AppState;
@@ -40,6 +41,10 @@ pub struct SettingsPage {
4041 pub members: Vec<String>,
4142 /// Erreur inline du bloc invitation.
4243 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,
4348 pub sent: Vec<SentView>,
4449 pub can_leave: bool,
4550 }
@@ -57,33 +62,35 @@ pub async fn settings_page(
5762 ctx: ActiveTeam,
5863 Query(q): Query<SettingsQuery>,
5964 ) -> Result<HtmlTemplate<SettingsPage>, AppError> {
65+ let tr = ctx.tr();
6066 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()),
6976 _ => 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()),
7380 _ => None,
7481 },
7582 };
7683 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),
8087 _ => None,
8188 };
8289 let nom = q.nom.as_deref().unwrap_or("");
8390 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)),
8794 _ => None,
8895 };
8996
@@ -91,7 +98,7 @@ pub async fn settings_page(
9198 .await?
9299 .into_iter()
93100 .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),
95102 accept_action: format!("/invitations/{}/accepter", i.id),
96103 refuse_action: format!("/invitations/{}/refuser", i.id),
97104 })
@@ -99,11 +106,7 @@ pub async fn settings_page(
99106
100107 let team_count = teams::teams_of(&state.pool, ctx.user.id).await?.len();
101108 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);
107110
108111 let members = teams::members(&state.pool, ctx.team_id)
109112 .await?
@@ -116,15 +119,26 @@ pub async fn settings_page(
116119 .into_iter()
117120 .map(|i| SentView {
118121 name: i.invitee,
119- meta: format!("invité par {} · en attente", i.inviter),
122+ meta: p1(tr.invited_by_pat, &i.inviter),
120123 cancel_action: format!("/invitations/{}/annuler", i.id),
121124 })
122125 .collect();
123126
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();
124135 Ok(HtmlTemplate(SettingsPage {
125136 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?,
127138 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),
128142 invites,
129143 account_line,
130144 members,
@@ -149,6 +163,7 @@ pub struct CategoryView {
149163 pub struct CategoriesPage {
150164 pub shell: Shell,
151165 pub csrf: String,
166+ pub hint: String,
152167 pub categories: Vec<CategoryView>,
153168 }
154169
@@ -157,15 +172,14 @@ pub async fn categories_page(
157172 ctx: ActiveTeam,
158173 Query(q): Query<SettingsQuery>,
159174 ) -> Result<HtmlTemplate<CategoriesPage>, AppError> {
175+ let tr = ctx.tr();
160176 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()),
169183 _ => None,
170184 };
171185 let by_creation = categories::list_by_creation(&state.pool, ctx.team_id).await?;
@@ -184,17 +198,47 @@ pub async fn categories_page(
184198 .collect();
185199 Ok(HtmlTemplate(CategoriesPage {
186200 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?,
188203 categories,
189204 }))
190205 }
191206
192207 #[derive(Deserialize)]
193-pub struct CategoryFormb {
208+pub struct CategoryForm {
194209 name: String,
195210 csrf: String,
196211 }
197212
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+
198242 fn is_unique_violation(e: &sqlx::Error) -> bool {
199243 matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())
200244 }
@@ -202,7 +246,7 @@ fn is_unique_violation(e: &sqlx::Error) -> bool {
202246 pub async fn category_create(
203247 State(state): State<AppState>,
204248 ctx: ActiveTeam,
205- Form(form): Form<CategoryFormb>,
249+ Form(form): Form<CategoryForm>,
206250 ) -> Result<Redirect, AppError> {
207251 if !ctx.user.check_csrf(&state, &form.csrf) {
208252 return Err(AppError::Csrf);
@@ -222,7 +266,7 @@ pub async fn category_rename(
222266 State(state): State<AppState>,
223267 ctx: ActiveTeam,
224268 Path(id): Path<Uuid>,
225- Form(form): Form<CategoryFormb>,
269+ Form(form): Form<CategoryForm>,
226270 ) -> Result<Redirect, AppError> {
227271 if !ctx.user.check_csrf(&state, &form.csrf) {
228272 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(
8989
9090 let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors);
9191
92+ let tr = ctx.tr();
9293 let (week_chart, mut sheets) = build_chart(
9394 "sem",
94- "Cette semaine",
95- &week.range_label(),
95+ tr.this_week,
96+ &week.range_label(ctx.lang),
9697 &all_users,
9798 &week_rows,
9899 &colors,
@@ -100,11 +101,12 @@ pub async fn stats_page(
100101 prev: href(week.prev(), month, year),
101102 next: (week != Week::current()).then(|| href(week.next(), month, year)),
102103 }),
104+ tr,
103105 );
104106 let (month_chart, s2) = build_chart(
105107 "mois",
106- "Ce mois-ci",
107- &month.label(),
108+ tr.this_month,
109+ &month.label(ctx.lang),
108110 &all_users,
109111 &month_rows,
110112 &colors,
@@ -112,11 +114,12 @@ pub async fn stats_page(
112114 prev: href(week, month.prev(), year),
113115 next: (month != Month::current()).then(|| href(week, month.next(), year)),
114116 }),
117+ tr,
115118 );
116119 let (year_chart, s3) = build_chart(
117120 "an",
118- "Cette année",
119- &year.label(),
121+ tr.this_year,
122+ &year.label(ctx.lang),
120123 &all_users,
121124 &year_rows,
122125 &colors,
@@ -124,22 +127,24 @@ pub async fn stats_page(
124127 prev: href(week, month, year.prev()),
125128 next: (year != Year::current()).then(|| href(week, month, year.next())),
126129 }),
130+ tr,
127131 );
128132 let (total_chart, s4) = build_chart(
129133 "tot",
130- "Depuis le début",
131- "Tout l'historique",
134+ tr.all_time,
135+ tr.all_history,
132136 &all_users,
133137 &total_rows,
134138 &colors,
135139 None,
140+ tr,
136141 );
137142 sheets.extend(s2);
138143 sheets.extend(s3);
139144 sheets.extend(s4);
140145
141146 Ok(HtmlTemplate(StatsPage {
142- shell: shell(&state, &ctx, "Statistiques", "stats", None).await?,
147+ shell: shell(&state, &ctx, tr.title_stats, "stats", None).await?,
143148 legend,
144149 charts: vec![week_chart, month_chart, year_chart, total_chart],
145150 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;
1414 use crate::db::teams;
1515 use crate::db::todos::{self, STATE_IN_PROGRESS, STATE_TODO, TodoRow};
1616 use crate::error::AppError;
17+use crate::i18n::{Tr, p1, p2};
1718 use crate::period::{day_label, day_month, paris_today};
1819 use crate::render::{HtmlTemplate, Shell};
1920 use crate::routes::shell;
@@ -33,6 +34,8 @@ pub struct AsgChip {
3334 pub struct TodoCard {
3435 pub id: Uuid,
3536 pub name: String,
37+ /// « Modifier la tâche X » — le verbe se place différemment selon la langue.
38+ pub edit_aria: String,
3639 pub color: &'static str,
3740 /// « pris par julien · 5 août », « depuis le 3 août », « à partir du… »
3841 pub meta: String,
@@ -53,6 +56,7 @@ pub struct TodoCard {
5356 pub struct TodosPage {
5457 pub shell: Shell,
5558 pub csrf: String,
59+ pub empty_body: String,
5660 pub doing: Vec<TodoCard>,
5761 pub open: Vec<TodoCard>,
5862 pub later: Vec<TodoCard>,
@@ -74,16 +78,16 @@ pub struct ListQuery {
7478 ok: Option<String>,
7579 }
7680
77-fn toast_of(ok: Option<&str>) -> Option<String> {
81+fn toast_of(tr: &Tr, ok: Option<&str>) -> Option<String> {
7882 Some(
7983 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,
8791 _ => return None,
8892 }
8993 .to_string(),
@@ -105,6 +109,7 @@ pub async fn todos_page(
105109 Query(q): Query<ListQuery>,
106110 ) -> Result<HtmlTemplate<TodosPage>, AppError> {
107111 let today = paris_today();
112+ let tr = ctx.tr();
108113 let me = ctx.user.id;
109114 let doing_on = q.encours.as_deref() == Some("moi");
110115 let open_on = q.afaire.as_deref() == Some("moi");
@@ -125,18 +130,19 @@ pub async fn todos_page(
125130 let meta = if t.state_id == STATE_IN_PROGRESS {
126131 let on = t
127132 .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()))
129134 .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)
131136 } else if late {
132- format!("depuis le {}", day_month(t.activation_date))
137+ p1(tr.since_pat, day_month(ctx.lang, t.activation_date))
133138 } else if t.activation_date == today {
134- "activée aujourd'hui".to_string()
139+ tr.activated_today.to_string()
135140 } else {
136- format!("à partir du {}", day_month(t.activation_date))
141+ p1(tr.from_pat, day_month(ctx.lang, t.activation_date))
137142 };
138143 TodoCard {
139144 id: t.id,
145+ edit_aria: p1(tr.edit_todo_aria_pat, &t.name),
140146 name: t.name.clone(),
141147 color: colors.get(&t.category_id).copied().unwrap_or("#a19786"),
142148 meta,
@@ -188,9 +194,10 @@ pub async fn todos_page(
188194 let open = select(STATE_TODO, true, open_on);
189195 let later = select(STATE_TODO, false, false);
190196
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?;
192198 Ok(HtmlTemplate(TodosPage {
193199 csrf: ctx.user.csrf_token(&state),
200+ empty_body: p1(tr.todos_empty_body_pat, &ctx.team_name),
194201 empty: rows.is_empty(),
195202 has_doing,
196203 has_open,
@@ -235,11 +242,17 @@ pub struct TodoFormPage {
235242 pub error: Option<&'static str>,
236243 }
237244
245+#[derive(Deserialize)]
246+pub struct FormQuery {
247+ err: Option<String>,
248+}
249+
238250 pub async fn todo_new(
239251 State(state): State<AppState>,
240252 ctx: ActiveTeam,
253+ Query(q): Query<FormQuery>,
241254 ) -> Result<HtmlTemplate<TodoFormPage>, AppError> {
242- form_page(&state, &ctx, None, None).await
255+ form_page(&state, &ctx, None, None, q.err.as_deref()).await
243256 }
244257
245258 pub async fn todo_edit(
@@ -251,7 +264,7 @@ pub async fn todo_edit(
251264 .await?
252265 .ok_or(AppError::NotFound)?;
253266 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
255268 }
256269
257270 async fn form_page(
@@ -259,13 +272,15 @@ async fn form_page(
259272 ctx: &ActiveTeam,
260273 todo: Option<TodoRow>,
261274 assigned: Option<Vec<Uuid>>,
275+ err: Option<&str>,
262276 ) -> Result<HtmlTemplate<TodoFormPage>, AppError> {
263277 let all = categories::list_by_creation(&state.pool, ctx.team_id).await?;
264278 let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>());
265279 let date = todo.as_ref().map(|t| t.activation_date).unwrap_or_else(paris_today);
266280 let selected_cat = todo.as_ref().map(|t| t.category_id);
267281 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 };
269284 let shell = shell(state, ctx, title, "todos", None).await?;
270285 Ok(HtmlTemplate(TodoFormPage {
271286 csrf: ctx.user.csrf_token(state),
@@ -282,17 +297,17 @@ async fn form_page(
282297 })
283298 .collect(),
284299 date,
285- date_label: day_label(date),
300+ date_label: day_label(ctx.lang, date),
286301 members: teams::members(&state.pool, ctx.team_id)
287302 .await?
288303 .into_iter()
289304 .map(|m| MemberChip {
290305 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 },
292307 id: m.id,
293308 })
294309 .collect(),
295- error: None,
310+ error: (err == Some("incomplet")).then_some(tr.err_todo_incomplete),
296311 shell,
297312 }))
298313 }
@@ -348,7 +363,7 @@ pub async fn todo_create(
348363 return Ok(Redirect::to("/taches/nouvelle?err=incomplet"));
349364 }
350365 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())),
352367 _ => Ok(Redirect::to("/taches?ok=creee")),
353368 }
354369 }
@@ -499,12 +514,13 @@ pub async fn todo_done_page(
499514 let minutes = q.minutes.unwrap_or(30).max(1);
500515 let date = q.date.unwrap_or(today).min(today);
501516 let next = q.prochaine.unwrap_or(proposal.date);
517+ let tr = ctx.tr();
502518 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),
505521 };
506522
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?;
508524 Ok(HtmlTemplate(TodoDonePage {
509525 csrf: ctx.user.csrf_token(&state),
510526 id,
@@ -534,7 +550,7 @@ pub async fn todo_done_page(
534550 day_prev: done_href(id, minutes, date - Duration::days(1), next),
535551 day_next: (date < today).then(|| done_href(id, minutes, date + Duration::days(1), next)),
536552 next,
537- next_label: day_label(next),
553+ next_label: day_label(ctx.lang, next),
538554 next_prev: done_href(id, minutes, date, next - Duration::days(1)),
539555 next_next: done_href(id, minutes, date, next + Duration::days(1)),
540556 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 = t131 let on = t
127 .taken_at132 .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).await255+ 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)).await267+ 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;
77
88 use crate::auth::SESSION_SECONDS;
99 use crate::auth::extractor::{AuthRedirect, AuthUser, authenticate};
10-use crate::db::teams;
10+use crate::db::{teams, users};
1111 use crate::error::AppError;
12+use crate::i18n::{Lang, Tr};
1213 use crate::state::AppState;
1314
1415 /// Équipe active par appareil — un simple UUID non signé : l'appartenance
@@ -36,10 +37,19 @@ pub fn clear_team_cookie() -> Cookie<'static> {
3637 /// appartenance (sans réécrire le cookie).
3738 pub struct ActiveTeam {
3839 pub user: AuthUser,
40+ pub username: String,
41+ /// Langue de l'interface — par utilisateur, stockée en base.
42+ pub lang: Lang,
3943 pub team_id: Uuid,
4044 pub team_name: String,
4145 }
4246
47+impl ActiveTeam {
48+ pub fn tr(&self) -> &'static Tr {
49+ self.lang.tr()
50+ }
51+}
52+
4353 pub enum TeamRejection {
4454 Auth(AuthRedirect),
4555 App(AppError),
@@ -68,6 +78,10 @@ impl FromRequestParts<AppState> for ActiveTeam {
6878 state: &AppState,
6979 ) -> Result<Self, TeamRejection> {
7080 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);
7185 let jar = CookieJar::from_headers(&parts.headers);
7286 let wanted = jar
7387 .get(TEAM_COOKIE)
@@ -77,6 +91,8 @@ impl FromRequestParts<AppState> for ActiveTeam {
7791 if let Some((id, name)) = teams::membership(&state.pool, user.id, team_id).await? {
7892 return Ok(ActiveTeam {
7993 user,
94+ username,
95+ lang,
8096 team_id: id,
8197 team_name: name,
8298 });
@@ -85,6 +101,8 @@ impl FromRequestParts<AppState> for ActiveTeam {
85101 match teams::oldest_membership(&state.pool, user.id).await? {
86102 Some((id, name)) => Ok(ActiveTeam {
87103 user,
104+ username,
105+ lang,
88106 team_id: id,
89107 team_name: name,
90108 }),
@@ -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'appartenance15 /// É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 = jar86 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 @@
33 {% if let Some(m) = measured %}
44 <div class="measure-card">
55 <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 %}
77 <form method="post" action="/timer/annuler" style="margin:var(--space-2) 0 0">
88 <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>
1010 </form>
1111 </div>
1212 {% endif %}
1313 {% 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>
1515 {% 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>
1717 <div class="category-pick">
1818 {% for t in categories %}
1919 <a href="{{ t.href }}">
@@ -24,5 +24,5 @@
2424 {% endfor %}
2525 </div>
2626 {% 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>
2828 {% 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 @@
11 {% extends "app.html" %}
22 {% 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>
44 <div class="picked">
55 <span class="dot dot-16" style="background:{{ picked_color }}"></span>
66 <span class="pname">{{ picked_name }}</span>
@@ -10,20 +10,20 @@
1010 <input type="hidden" name="csrf" value="{{ csrf }}">
1111 <input type="hidden" name="categorie" value="{{ categorie }}">
1212
13- <label class="mini-label" for="minutes">Durée</label>
13+ <label class="mini-label" for="minutes">{{ shell.tr.duration_label }}</label>
1414 <div class="duration-row">
1515 <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 }}">
1717 <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>
1818 </a>
1919 <input class="input minutes-input" id="minutes" name="minutes" type="number" inputmode="numeric"
2020 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 }}">
2222 <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>
2323 </a>
2424 </div>
2525 <div>
26- <div class="unit">minutes</div>
26+ <div class="unit">{{ shell.tr.minutes_unit }}</div>
2727 <div class="pretty" id="minutes-pretty">{{ minutes_pretty }}</div>
2828 </div>
2929 </div>
@@ -75,14 +75,14 @@
7575 {% endfor %}
7676 </div>
7777
78- <label class="mini-label" for="date">Date</label>
78+ <label class="mini-label" for="date">{{ shell.tr.date_label }}</label>
7979 <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 }}">
8181 <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>
8282 </a>
8383 <input class="input" id="date" name="date" type="date" value="{{ date }}" required>
8484 {% 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 }}">
8686 <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>
8787 </a>
8888 {% else %}
@@ -93,6 +93,6 @@
9393 </div>
9494 <p class="date-label">{{ date_label }}</p>
9595
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>
9797 </form>
9898 {% 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 @@
11 {% extends "base.html" %}
22 {% block title %}{{ shell.title }} · time-time{% endblock %}
3+{% block lang %}{{ shell.lang_code }}{% endblock %}
34 {% block body %}
45 <header class="app-header">
56 <div class="app-topline">
67 {% 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 }}">
89 <span class="team-dot" style="background:{{ shell.team_color }}"></span>
910 {{ shell.team_name }}
1011 <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 @@
2627 {% if t.running %}
2728 <form class="timer-slot" method="post" action="/timer/arreter">
2829 <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 }}">
3031 <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>
3132 <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span>
3233 <span class="t-note">{{ t.note }}</span>
@@ -35,7 +36,7 @@
3536 {% else if t.foreign %}
3637 <form class="timer-slot" method="post" action="/timer/arreter">
3738 <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 }}">
3940 <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>
4041 <span class="t-elapsed">{{ t.label }}</span>
4142 <span class="t-note">{{ t.note }}</span>
@@ -43,7 +44,7 @@
4344 </form>
4445 {% else %}
4546 <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 }}">
4748 <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>
4849 <span class="t-elapsed">{{ t.label }}</span>
4950 </a>
@@ -53,19 +54,19 @@
5354 <form class="timer-slot" method="post" action="/timer/demarrer">
5455 <input type="hidden" name="csrf" value="{{ shell.csrf }}">
5556 <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 }}">
5758 <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>
5960 </button>
6061 </form>
6162 {% endif %}
6263 {% 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>
6566 <div class="sheet">
6667 <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>
6970 </div>
7071 <div style="display:grid;gap:var(--space-2)">
7172 {% for t in shell.teams %}
@@ -91,25 +92,25 @@
9192 <nav class="tabbar">
9293 <a class="tab" href="/temps/nouveau" {% if shell.tab == "add" %}aria-current="page"{% endif %}>
9394 <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>
9596 </a>
9697 <a class="tab" href="/stats" {% if shell.tab == "stats" %}aria-current="page"{% endif %}>
9798 <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>
99100 </a>
100101 <a class="tab" href="/taches" {% if shell.tab == "todos" %}aria-current="page"{% endif %}>
101102 <span class="tab-ico">
102103 <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>
103104 {% if shell.todo_count > 0 %}<span class="tab-badge">{{ shell.todo_count }}</span>{% endif %}
104105 </span>
105- <span>Tâches</span>
106+ <span>{{ shell.tr.tab_todos }}</span>
106107 </a>
107108 <a class="tab" href="/parametres" {% if shell.tab == "settings" %}aria-current="page"{% endif %}>
108109 <span class="tab-ico">
109110 <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>
110111 {% if shell.invite_count > 0 %}<span class="tab-badge">{{ shell.invite_count }}</span>{% endif %}
111112 </span>
112- <span>Réglages</span>
113+ <span>{{ shell.tr.tab_settings }}</span>
113114 </a>
114115 </nav>
115116 <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 @@
11 <!doctype html>
2-<html lang="fr">
2+<html lang="{% block lang %}fr{% endblock %}">
33 <head>
44 <meta charset="utf-8">
55 <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 @@
11 {% extends "app.html" %}
22 {% 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>
44 <form method="post" action="/parametres/categories" class="category-add-row">
55 <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>
88 </form>
99 {% for t in categories %}
1010 <div class="category-row {% if t.archived %}archived{% endif %}">
1111 <span class="dot dot-12" style="background:{{ t.color }}"></span>
1212 <form method="post" action="{{ t.rename_action }}" class="rename">
1313 <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 }}">
1515 {# tabindex=-1 : la soumission clavier passe par Entrée dans l'input ;
1616 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>
1818 </form>
1919 <form method="post" action="{{ t.toggle_action }}">
2020 <input type="hidden" name="csrf" value="{{ csrf }}">
2121 {% if t.archived %}
2222 <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>
2424 {% 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 }}">
2626 <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>
2727 </button>
2828 {% endif %}
2929 </form>
3030 </div>
3131 {% 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>
3434 {% 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 @@
11 {% extends "app.html" %}
22 {% 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>
44 <form method="post" action="{{ action }}">
55 <input type="hidden" name="csrf" value="{{ csrf }}">
66 <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>
88 <select class="input" id="categorie" name="categorie" required style="min-height:44px">
99 {% for t in categories %}
1010 <option value="{{ t.id }}"{% if t.selected %} selected{% endif %}>{{ t.name }}</option>
@@ -13,19 +13,19 @@
1313 </div>
1414 <div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-4)">
1515 <div class="field" style="flex:1">
16- <label for="minutes">Durée (min)</label>
16+ <label for="minutes">{{ shell.tr.duration_min_label }}</label>
1717 <input class="input" id="minutes" name="minutes" type="number" inputmode="numeric"
1818 min="1" required value="{{ minutes }}" style="min-height:44px">
1919 </div>
2020 <div class="field" style="flex:1.4">
21- <label for="date">Date</label>
21+ <label for="date">{{ shell.tr.date_label }}</label>
2222 <input class="input" id="date" name="date" type="date" value="{{ date }}" required style="min-height:44px">
2323 </div>
2424 </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>
2626 </form>
2727 <form method="post" action="{{ delete_action }}" style="margin-top:var(--space-3)">
2828 <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>
3030 </form>
3131 {% 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 @@
11 {% 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 %}
34 {% block body %}
45 <main class="login">
56 <div class="login-icon">
67 <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>
78 </div>
89 <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>
1011 <form method="post" action="/login">
1112 <div class="field" style="margin-bottom:var(--space-3)">
12- <label for="username">Identifiant</label>
13+ <label for="username">{{ tr.username_label }}</label>
1314 <input class="input" id="username" name="username" autocomplete="username"
1415 autocapitalize="none" autocorrect="off" required autofocus>
1516 </div>
1617 <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>
1819 <input class="input" id="password" name="password" type="password" autocomplete="current-password" required>
1920 </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>
2223 </form>
2324 </main>
2425 {% 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 @@
44 <div style="margin-bottom:var(--space-6)">
55 {% for inv in invites %}
66 <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>
88 <p>{{ inv.text }}</p>
99 <div style="display:flex;gap:var(--space-2)">
1010 <form method="post" action="{{ inv.accept_action }}" style="flex:1;display:flex">
1111 <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>
1313 </form>
1414 <form method="post" action="{{ inv.refuse_action }}" style="display:flex">
1515 <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>
1717 </form>
1818 </div>
1919 </div>
@@ -22,15 +22,15 @@
2222 {% endif %}
2323
2424 <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>
2626 <div style="font-size:19px;font-family:var(--font-heading)">{{ shell.username }}</div>
2727 <p class="card-body">{{ account_line }}</p>
2828 </div>
2929
3030 <a class="card times-link" href="/temps" style="margin-bottom:var(--space-6)">
3131 <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>
3434 </span>
3535 <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>
3636 </a>
@@ -38,16 +38,16 @@
3838 <form method="post" action="/equipes/renommer">
3939 <input type="hidden" name="csrf" value="{{ csrf }}">
4040 <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>
4242 <div style="display:flex;gap:var(--space-2)">
4343 <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>
4545 </div>
4646 </div>
4747 </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>
4949
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>
5151 <div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-4)">
5252 {% for m in members %}<span class="tag tag-neutral">{{ m }}</span>{% endfor %}
5353 </div>
@@ -55,18 +55,18 @@
5555 <form method="post" action="/equipes/inviter">
5656 <input type="hidden" name="csrf" value="{{ csrf }}">
5757 <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>
5959 <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>
6262 </div>
6363 </div>
6464 </form>
6565 {% 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>
6767
6868 {% 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>
7070 {% for si in sent %}
7171 <div class="sent-row">
7272 <div style="flex:1;min-width:0">
@@ -75,54 +75,67 @@
7575 </div>
7676 <form method="post" action="{{ si.cancel_action }}" style="margin:0">
7777 <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>
7979 </form>
8080 </div>
8181 {% endfor %}
8282 {% endif %}
8383
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>
8585 <form method="post" action="/equipes">
8686 <input type="hidden" name="csrf" value="{{ csrf }}">
8787 <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>
9090 </div>
9191 </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>
9393
9494 {% if can_leave %}
9595 <form method="post" action="/equipes/quitter">
9696 <input type="hidden" name="csrf" value="{{ csrf }}">
9797 <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>
9999 </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>
101101 {% endif %}
102102
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>
104117 <form method="post" action="/parametres/mot-de-passe">
105118 <input type="hidden" name="csrf" value="{{ csrf }}">
106119 <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>
108121 <input class="input" id="ancien" name="ancien" type="password" autocomplete="current-password" required style="min-height:44px">
109122 </div>
110123 <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>
112125 <input class="input" id="nouveau" name="nouveau" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px">
113126 </div>
114127 <div class="field" style="margin-bottom:var(--space-4)">
115- <label for="confirmation">Confirmation</label>
128+ <label for="confirmation">{{ shell.tr.confirmation }}</label>
116129 <input class="input" id="confirmation" name="confirmation" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px">
117130 </div>
118131 {% 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>
120133 </form>
121134
122135 <form method="post" action="/logout">
123136 <input type="hidden" name="csrf" value="{{ csrf }}">
124137 <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>
126139 </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>
128141 {% 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 @@
11 {% extends "app.html" %}
22 {% 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>
44 {% if !legend.is_empty() %}
55 <ul class="legend">
66 {% for item in legend %}
@@ -15,11 +15,11 @@
1515 <h2>{{ c.title }}</h2>
1616 {% if c.nav %}
1717 <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 }}">
1919 <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>
2020 </a>
2121 {% 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 }}">
2323 <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>
2424 </a>
2525 {% else %}
@@ -33,7 +33,7 @@
3333 <p class="chart-range">{{ c.range }}</p>
3434
3535 {% 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>
3737 {% else %}
3838 <div class="cols">
3939 {% for row in c.rows %}
@@ -54,17 +54,17 @@
5454 {% endfor %}
5555
5656 {% 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>
5959 <div class="sheet">
6060 <div class="sheet-head">
6161 <span class="dot dot-14" style="background:{{ s.color }}"></span>
6262 <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>
6464 </div>
6565 <div class="sheet-figures">
6666 <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>
6868 </div>
6969 <p class="sheet-note">{{ s.period }} · {{ s.count }}</p>
7070 </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 @@
11 {% extends "app.html" %}
22 {% block content %}
33 <div class="chrono-card">
4- <p class="chrono-kicker">Minuteur en cours</p>
4+ <p class="chrono-kicker">{{ shell.tr.chrono_kicker }}</p>
55 <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>
77 </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>
99 <div style="display:flex;flex-direction:column;gap:var(--space-3)">
1010 <form method="post" action="/timer/arreter" style="margin:0;display:flex">
1111 <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>
1313 </form>
1414 <form method="post" action="/timer/abandonner" style="margin:0;display:flex">
1515 <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>
1717 </form>
1818 </div>
1919 <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 @@
11 {% extends "app.html" %}
22 {% block content %}
33 {% 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>
55 {% else %}
66 <ul class="times">
77 {% for e in entries %}
@@ -12,7 +12,7 @@
1212 <div class="tdate">{{ e.date }}</div>
1313 </div>
1414 <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 }}">
1616 <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>
1717 </a>
1818 </li>
@@ -20,6 +20,6 @@
2020 </ul>
2121 {% endif %}
2222 {% 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>
2424 {% endif %}
2525 {% 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 @@
11 {% extends "app.html" %}
22 {% 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>
44
55 <div class="done-title">
66 <span class="done-check" aria-hidden="true">
@@ -15,9 +15,9 @@
1515
1616 <section class="done-sec">
1717 <div class="done-head">
18- <h2>Durée passée</h2>
18+ <h2>{{ shell.tr.duration_spent }}</h2>
1919 <label class="swt-check">
20- Enregistrer un temps
20+ {{ shell.tr.record_time }}
2121 <input type="checkbox" name="avec_temps" value="1" checked>
2222 <span class="swt"><span class="knob"></span></span>
2323 </label>
@@ -25,17 +25,17 @@
2525 <div class="done-body">
2626 <div class="duration-row">
2727 <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 }}">
2929 <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>
3030 </a>
3131 <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 }}">
3434 <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>
3535 </a>
3636 </div>
3737 <div>
38- <div class="unit">minutes</div>
38+ <div class="unit">{{ shell.tr.minutes_unit }}</div>
3939 <div class="pretty" id="minutes-pretty">{{ minutes_pretty }}</div>
4040 </div>
4141 </div>
@@ -44,14 +44,14 @@
4444 <a class="preset" href="{{ p.href }}" data-minutes="{{ p.minutes }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a>
4545 {% endfor %}
4646 </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>
4848 <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 }}">
5050 <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>
5151 </a>
5252 <input class="input" id="done-date" name="date" type="date" value="{{ date }}" max="{{ today }}" required>
5353 {% 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 }}">
5555 <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>
5656 </a>
5757 {% else %}
@@ -65,9 +65,9 @@
6565
6666 <section class="done-sec" style="margin-top:var(--space-8)">
6767 <div class="done-head">
68- <h2>Reprogrammer</h2>
68+ <h2>{{ shell.tr.reschedule }}</h2>
6969 <label class="swt-check">
70- Reprogrammer
70+ {{ shell.tr.reschedule }}
7171 <input type="checkbox" name="reprogrammer" value="1" checked>
7272 <span class="swt"><span class="knob"></span></span>
7373 </label>
@@ -75,11 +75,11 @@
7575 <div class="done-body">
7676 <p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ rhythm }}</p>
7777 <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 }}">
7979 <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>
8080 </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 }}">
8383 <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>
8484 </a>
8585 </div>
@@ -87,7 +87,7 @@
8787 </div>
8888 </section>
8989
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>
9191 </form>
9292
9393 {% 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 temps20+ {{ 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- Reprogrammer70+ {{ 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 @@
11 {% extends "app.html" %}
22 {% 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>
44
55 {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %}
66
@@ -8,13 +8,13 @@
88 <input type="hidden" name="csrf" value="{{ csrf }}">
99
1010 <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>
1212 <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">
1414 </div>
1515
1616 <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>
1818 </div>
1919 <div class="chips" role="radiogroup" aria-labelledby="todo-cat-label" style="margin-bottom:var(--space-6)">
2020 {% for c in cats %}
@@ -26,14 +26,14 @@
2626 </div>
2727
2828 <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>
3030 <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>
3232 <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>
3333 </button>
3434 <input class="input" id="todo-date" name="date" type="date" value="{{ date }}" required
3535 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>
3737 <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>
3838 </button>
3939 </div>
@@ -41,7 +41,7 @@
4141 <p id="todo-date-label" style="font-size:13px;opacity:.7;margin:0 0 var(--space-6)">{{ date_label }}</p>
4242
4343 <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>
4545 </div>
4646 <div class="chips" role="group" aria-labelledby="todo-asg-label" style="margin-bottom:var(--space-2)">
4747 {% for m in members %}
@@ -51,17 +51,17 @@
5151 </label>
5252 {% endfor %}
5353 </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>
5555
5656 <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 %}
5858 </button>
5959 </form>
6060
6161 {% if let Some(id) = edit_id %}
6262 <form method="post" action="/taches/{{ id }}/supprimer" style="margin-top:var(--space-3)">
6363 <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>
6565 </form>
6666 {% endif %}
6767
@@ -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 }}" required12 <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 }}" required34 <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 @@
11 {% extends "app.html" %}
22 {% block content %}
33 <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>
55 </div>
66
77 {% if empty %}
88 <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>
1111 </div>
1212 {% endif %}
1313
1414 {% if has_doing %}
1515 <div class="blk-head">
16- <h2>En cours</h2>
16+ <h2>{{ shell.tr.doing_block }}</h2>
1717 <a class="blk-toggle" role="switch" aria-checked="{{ doing_filter_on }}" href="{{ doing_toggle_href }}">
18- Mes tâches
18+ {{ shell.tr.my_tasks }}
1919 <span class="swt{% if doing_filter_on %} on{% endif %}"><span class="knob"></span></span>
2020 </a>
2121 </div>
2222 {% 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>
2424 {% else %}
2525 <div class="todo-grid">
2626 {% for t in doing %}
@@ -37,17 +37,17 @@
3737 {% endif %}
3838 </div>
3939 {% 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 }}">
4141 <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>
4242 </a>
4343 {% endif %}
4444 </div>
4545 {% if t.mine %}
4646 <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>
4848 <form method="post" action="/taches/{{ t.id }}/relacher">
4949 <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>
5151 </form>
5252 </div>
5353 {% endif %}
@@ -59,14 +59,14 @@
5959
6060 {% if has_open %}
6161 <div class="blk-head">
62- <h2>À faire</h2>
62+ <h2>{{ shell.tr.todo_block }}</h2>
6363 <a class="blk-toggle" role="switch" aria-checked="{{ open_filter_on }}" href="{{ open_toggle_href }}">
64- Mes tâches
64+ {{ shell.tr.my_tasks }}
6565 <span class="swt{% if open_filter_on %} on{% endif %}"><span class="knob"></span></span>
6666 </a>
6767 </div>
6868 {% 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>
7070 {% else %}
7171 <div class="todo-grid">
7272 {% for t in open %}
@@ -82,16 +82,16 @@
8282 </div>
8383 {% endif %}
8484 </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 }}">
8686 <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>
8787 </a>
8888 </div>
8989 <div class="todo-actions">
9090 <form method="post" action="/taches/{{ t.id }}/prendre">
9191 <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>
9393 </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>
9595 </div>
9696 </div>
9797 {% endfor %}
@@ -101,7 +101,7 @@
101101
102102 {% if !later.is_empty() %}
103103 <details class="later">
104- <summary>À venir · {{ later.len() }}</summary>
104+ <summary>{{ shell.tr.upcoming }} · {{ later.len() }}</summary>
105105 <div style="margin-top:var(--space-3)">
106106 {% for t in later %}
107107 <div class="later-row">
@@ -115,7 +115,7 @@
115115 </div>
116116 {% endif %}
117117 </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 }}">
119119 <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>
120120 </a>
121121 </div>
@@ -141,11 +141,11 @@
141141 var wrap = document.createElement('div');
142142 wrap.className = 'sheet-wrap open';
143143 wrap.setAttribute('role', 'dialog');
144- wrap.setAttribute('aria-label', 'Tâche effectuée');
144+ wrap.setAttribute('aria-label', '{{ shell.tr.title_todo_done }}');
145145 wrap.innerHTML = '<span class="sheet-backdrop"></span>' +
146146 '<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>';
149149 var sheet = wrap.querySelector('.sheet');
150150 if (title) sheet.appendChild(title);
151151 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âches18+ {{ 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âches64+ {{ 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);