feat: thème contraste élevé par utilisateur (proposition Claude Design) — tokens HC dans style.css, palette de gris jumelle, sélecteur Affichage dans Réglages (FR/EN/DE), migration 0008Unverified
Claude-Session: https://claude.ai/code/session_01FPKHKGfqvnvXKY9tNhYmkh
90be069 parent: a32b594 modified
assets/style.css +64 -0 | @@ -50,6 +50,49 @@ | ||
| 50 | 50 | --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent); |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | +/* ═══ Contraste élevé (users.theme = 'hc', data-theme sur <html>) ═══ | |
| 54 | + Noir et blanc, source : proposition THEME_HC du projet Claude Design. | |
| 55 | + Les ombres deviennent des liserés blancs (une ombre noire est invisible | |
| 56 | + sur fond noir) ; les widgets natifs passent en sombre (color-scheme). */ | |
| 57 | +:root[data-theme="hc"] { | |
| 58 | + color-scheme: dark; | |
| 59 | + --color-bg: #000000; | |
| 60 | + --color-surface: #141414; | |
| 61 | + --color-text: #ffffff; | |
| 62 | + --color-accent: #ffffff; | |
| 63 | + --color-accent-deep: #ffffff; | |
| 64 | + --color-accent-2: #d9d9d9; | |
| 65 | + --color-divider: rgba(255, 255, 255, .42); | |
| 66 | + | |
| 67 | + --color-neutral-100: #0a0a0a; | |
| 68 | + --color-neutral-200: #1c1c1c; | |
| 69 | + --color-neutral-300: #000000; | |
| 70 | + --color-neutral-500: #8a8a8a; | |
| 71 | + --color-neutral-800: #e6e6e6; | |
| 72 | + --color-neutral-900: #000000; | |
| 73 | + | |
| 74 | + --color-accent-100: #0d0d0d; | |
| 75 | + --color-accent-200: #1f1f1f; | |
| 76 | + --color-accent-300: #4d4d4d; | |
| 77 | + --color-accent-600: #e0e0e0; | |
| 78 | + --color-accent-700: #ffffff; | |
| 79 | + --color-accent-900: #ffffff; | |
| 80 | + | |
| 81 | + --color-accent-2-200: #242424; | |
| 82 | + --color-accent-2-900: #ffffff; | |
| 83 | + | |
| 84 | + --shadow-sm: 0 0 0 1px rgba(255, 255, 255, .28); | |
| 85 | + --shadow-md: 0 0 0 1px rgba(255, 255, 255, .34); | |
| 86 | + --shadow-lg: 0 0 0 1px rgba(255, 255, 255, .42); | |
| 87 | +} | |
| 88 | +/* Le toast s'inverse : son fond neutral-900 devient noir en HC. */ | |
| 89 | +:root[data-theme="hc"] .toast { background: #ffffff; color: #000000; } | |
| 90 | +/* Rail de l'interrupteur : neutral-300 = noir en HC, un liseré le dessine. */ | |
| 91 | +:root[data-theme="hc"] .swt { box-shadow: inset 0 0 0 1px var(--color-divider); } | |
| 92 | +/* Textes atténués des templates (inline) : estompe relevée, comme le design. | |
| 93 | + !important : seule façon de surclasser un style inline. */ | |
| 94 | +:root[data-theme="hc"] [style*="opacity:.7"] { opacity: .8 !important; } | |
| 95 | + | |
| 53 | 96 | *, *::before, *::after { box-sizing: border-box; } |
| 54 | 97 | html { height: 100%; } |
| 55 | 98 | body { |
| @@ -537,6 +580,27 @@ a.team-pill:hover { filter: brightness(.96); } | ||
| 537 | 580 | .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; } |
| 538 | 581 | .done-sec .done-body { transition: opacity .15s ease; } |
| 539 | 582 | .done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .35; } |
| 583 | +/* Sélecteur de thème (Réglages, section Affichage). */ | |
| 584 | +.theme-choice { | |
| 585 | + display: flex; align-items: center; gap: var(--space-3); width: 100%; | |
| 586 | + padding: var(--space-3); border-radius: var(--radius-md); cursor: pointer; | |
| 587 | + background: var(--color-surface); border: 1.5px solid transparent; | |
| 588 | + color: var(--color-text); font: inherit; text-align: left; | |
| 589 | +} | |
| 590 | +.theme-choice:hover { border-color: var(--color-accent-2); } | |
| 591 | +.theme-choice.current { | |
| 592 | + background: var(--color-accent-2-200); border-color: var(--color-accent-2); | |
| 593 | + color: var(--color-accent-2-900); | |
| 594 | +} | |
| 595 | +/* Échantillons en couleurs littérales : chaque pastille montre SON thème, | |
| 596 | + quel que soit le thème actif. */ | |
| 597 | +.theme-swatch { | |
| 598 | + width: 34px; height: 34px; flex: none; border-radius: var(--radius-md); | |
| 599 | + border: 1px solid var(--color-divider); | |
| 600 | +} | |
| 601 | +.swatch-organic { background: linear-gradient(135deg, #f5ead8 0 50%, #c67139 50% 100%); } | |
| 602 | +.swatch-hc { background: linear-gradient(135deg, #000 0 50%, #fff 50% 100%); } | |
| 603 | + | |
| 540 | 604 | /* Sélecteur de langue (Réglages). */ |
| 541 | 605 | .lang-pill { |
| 542 | 606 | display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px; |
| @@ -50,6 +50,49 @@ | |||
| 50 | --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent); | 50 | --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent); |
| 51 | } | 51 | } |
| 52 | 52 | ||
| 53 | +/* ═══ Contraste élevé (users.theme = 'hc', data-theme sur <html>) ═══ | ||
| 54 | + Noir et blanc, source : proposition THEME_HC du projet Claude Design. | ||
| 55 | + Les ombres deviennent des liserés blancs (une ombre noire est invisible | ||
| 56 | + sur fond noir) ; les widgets natifs passent en sombre (color-scheme). */ | ||
| 57 | +:root[data-theme="hc"] { | ||
| 58 | + color-scheme: dark; | ||
| 59 | + --color-bg: #000000; | ||
| 60 | + --color-surface: #141414; | ||
| 61 | + --color-text: #ffffff; | ||
| 62 | + --color-accent: #ffffff; | ||
| 63 | + --color-accent-deep: #ffffff; | ||
| 64 | + --color-accent-2: #d9d9d9; | ||
| 65 | + --color-divider: rgba(255, 255, 255, .42); | ||
| 66 | + | ||
| 67 | + --color-neutral-100: #0a0a0a; | ||
| 68 | + --color-neutral-200: #1c1c1c; | ||
| 69 | + --color-neutral-300: #000000; | ||
| 70 | + --color-neutral-500: #8a8a8a; | ||
| 71 | + --color-neutral-800: #e6e6e6; | ||
| 72 | + --color-neutral-900: #000000; | ||
| 73 | + | ||
| 74 | + --color-accent-100: #0d0d0d; | ||
| 75 | + --color-accent-200: #1f1f1f; | ||
| 76 | + --color-accent-300: #4d4d4d; | ||
| 77 | + --color-accent-600: #e0e0e0; | ||
| 78 | + --color-accent-700: #ffffff; | ||
| 79 | + --color-accent-900: #ffffff; | ||
| 80 | + | ||
| 81 | + --color-accent-2-200: #242424; | ||
| 82 | + --color-accent-2-900: #ffffff; | ||
| 83 | + | ||
| 84 | + --shadow-sm: 0 0 0 1px rgba(255, 255, 255, .28); | ||
| 85 | + --shadow-md: 0 0 0 1px rgba(255, 255, 255, .34); | ||
| 86 | + --shadow-lg: 0 0 0 1px rgba(255, 255, 255, .42); | ||
| 87 | +} | ||
| 88 | +/* Le toast s'inverse : son fond neutral-900 devient noir en HC. */ | ||
| 89 | +:root[data-theme="hc"] .toast { background: #ffffff; color: #000000; } | ||
| 90 | +/* Rail de l'interrupteur : neutral-300 = noir en HC, un liseré le dessine. */ | ||
| 91 | +:root[data-theme="hc"] .swt { box-shadow: inset 0 0 0 1px var(--color-divider); } | ||
| 92 | +/* Textes atténués des templates (inline) : estompe relevée, comme le design. | ||
| 93 | + !important : seule façon de surclasser un style inline. */ | ||
| 94 | +:root[data-theme="hc"] [style*="opacity:.7"] { opacity: .8 !important; } | ||
| 95 | + | ||
| 53 | *, *::before, *::after { box-sizing: border-box; } | 96 | *, *::before, *::after { box-sizing: border-box; } |
| 54 | html { height: 100%; } | 97 | html { height: 100%; } |
| 55 | body { | 98 | body { |
| @@ -537,6 +580,27 @@ a.team-pill:hover { filter: brightness(.96); } | |||
| 537 | .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; } | 580 | .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; } | 581 | .done-sec .done-body { transition: opacity .15s ease; } |
| 539 | .done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .35; } | 582 | .done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .35; } |
| 583 | +/* Sélecteur de thème (Réglages, section Affichage). */ | ||
| 584 | +.theme-choice { | ||
| 585 | + display: flex; align-items: center; gap: var(--space-3); width: 100%; | ||
| 586 | + padding: var(--space-3); border-radius: var(--radius-md); cursor: pointer; | ||
| 587 | + background: var(--color-surface); border: 1.5px solid transparent; | ||
| 588 | + color: var(--color-text); font: inherit; text-align: left; | ||
| 589 | +} | ||
| 590 | +.theme-choice:hover { border-color: var(--color-accent-2); } | ||
| 591 | +.theme-choice.current { | ||
| 592 | + background: var(--color-accent-2-200); border-color: var(--color-accent-2); | ||
| 593 | + color: var(--color-accent-2-900); | ||
| 594 | +} | ||
| 595 | +/* Échantillons en couleurs littérales : chaque pastille montre SON thème, | ||
| 596 | + quel que soit le thème actif. */ | ||
| 597 | +.theme-swatch { | ||
| 598 | + width: 34px; height: 34px; flex: none; border-radius: var(--radius-md); | ||
| 599 | + border: 1px solid var(--color-divider); | ||
| 600 | +} | ||
| 601 | +.swatch-organic { background: linear-gradient(135deg, #f5ead8 0 50%, #c67139 50% 100%); } | ||
| 602 | +.swatch-hc { background: linear-gradient(135deg, #000 0 50%, #fff 50% 100%); } | ||
| 603 | + | ||
| 540 | /* Sélecteur de langue (Réglages). */ | 604 | /* Sélecteur de langue (Réglages). */ |
| 541 | .lang-pill { | 605 | .lang-pill { |
| 542 | display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px; | 606 | display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px; |
added
migrations/0008_user_theme.sql +5 -0 | new file mode 100644 | ||
| @@ -0,0 +1,5 @@ | ||
| 1 | +-- Thème de l'interface, PAR UTILISATEUR (comme la langue) : le choix suit le | |
| 2 | +-- compte sur tous les appareils. 'organic' = style par défaut crème/terracotta, | |
| 3 | +-- 'hc' = contraste élevé noir et blanc (proposition du projet Claude Design). | |
| 4 | +ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'organic' | |
| 5 | + CHECK (theme IN ('organic', 'hc')); | |
| new file mode 100644 | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | +-- Thème de l'interface, PAR UTILISATEUR (comme la langue) : le choix suit le | ||
| 2 | +-- compte sur tous les appareils. 'organic' = style par défaut crème/terracotta, | ||
| 3 | +-- 'hc' = contraste élevé noir et blanc (proposition du projet Claude Design). | ||
| 4 | +ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'organic' | ||
| 5 | + CHECK (theme IN ('organic', 'hc')); | ||
modified
src/charts.rs +29 -9 | @@ -16,19 +16,34 @@ pub const PALETTE: [&str; 15] = [ | ||
| 16 | 16 | "#c98d8d", |
| 17 | 17 | ]; |
| 18 | 18 | |
| 19 | +/// Palette du thème contraste élevé — mêmes rangs que PALETTE, en niveaux de | |
| 20 | +/// gris (les 7 premières valeurs viennent du projet Design, l'extension | |
| 21 | +/// alterne foncé/clair pour garder des voisins distinguables). | |
| 22 | +pub const PALETTE_HC: [&str; 15] = [ | |
| 23 | + "#ffffff", "#9e9e9e", "#d6d6d6", "#6b6b6b", "#c0c0c0", "#828282", "#ebebeb", | |
| 24 | + "#494949", "#b0b0b0", "#767676", "#f5f5f5", "#5a5a5a", "#cbcbcb", "#8f8f8f", | |
| 25 | + "#e1e1e1", | |
| 26 | +]; | |
| 27 | + | |
| 28 | +fn palette(hc: bool) -> &'static [&'static str; 15] { | |
| 29 | + if hc { &PALETTE_HC } else { &PALETTE } | |
| 30 | +} | |
| 31 | + | |
| 19 | 32 | /// category_id → couleur, à partir de la liste des tâches triée par id (création). |
| 20 | -pub fn color_map(task_ids_in_creation_order: &[Uuid]) -> HashMap<Uuid, &'static str> { | |
| 33 | +pub fn color_map(task_ids_in_creation_order: &[Uuid], hc: bool) -> HashMap<Uuid, &'static str> { | |
| 34 | + let p = palette(hc); | |
| 21 | 35 | task_ids_in_creation_order |
| 22 | 36 | .iter() |
| 23 | 37 | .enumerate() |
| 24 | - .map(|(i, id)| (*id, PALETTE[i % PALETTE.len()])) | |
| 38 | + .map(|(i, id)| (*id, p[i % p.len()])) | |
| 25 | 39 | .collect() |
| 26 | 40 | } |
| 27 | 41 | |
| 28 | 42 | /// Couleur d'une équipe — dérivée du hash de son id (les bits bas d'un |
| 29 | 43 | /// UUIDv7 sont aléatoires), jamais stockée en base. |
| 30 | -pub fn team_color(team_id: Uuid) -> &'static str { | |
| 31 | - PALETTE[(team_id.as_u128() % PALETTE.len() as u128) as usize] | |
| 44 | +pub fn team_color(team_id: Uuid, hc: bool) -> &'static str { | |
| 45 | + let p = palette(hc); | |
| 46 | + p[(team_id.as_u128() % p.len() as u128) as usize] | |
| 32 | 47 | } |
| 33 | 48 | |
| 34 | 49 | /// « 45 min », « 2 h », « 12 h 30 » |
| @@ -216,7 +231,7 @@ mod tests { | ||
| 216 | 231 | } |
| 217 | 232 | |
| 218 | 233 | fn colors() -> HashMap<Uuid, &'static str> { |
| 219 | - color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)]) | |
| 234 | + color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)], false) | |
| 220 | 235 | } |
| 221 | 236 | |
| 222 | 237 | #[test] |
| @@ -260,16 +275,21 @@ mod tests { | ||
| 260 | 275 | #[test] |
| 261 | 276 | fn stable_colors_by_creation_order() { |
| 262 | 277 | let ids = [Uuid::from_u128(5), Uuid::from_u128(3)]; |
| 263 | - let m = color_map(&ids); | |
| 278 | + let m = color_map(&ids, false); | |
| 264 | 279 | assert_eq!(m[&ids[0]], PALETTE[0]); |
| 265 | 280 | assert_eq!(m[&ids[1]], PALETTE[1]); |
| 281 | + // Même rang dans les deux palettes : passer en HC ne mélange rien. | |
| 282 | + let hc = color_map(&ids, true); | |
| 283 | + assert_eq!(hc[&ids[0]], PALETTE_HC[0]); | |
| 284 | + assert_eq!(hc[&ids[1]], PALETTE_HC[1]); | |
| 266 | 285 | } |
| 267 | 286 | |
| 268 | 287 | #[test] |
| 269 | 288 | fn team_color_stable_and_in_palette() { |
| 270 | 289 | let id = Uuid::from_u128(0x1234_5678_9abc_def0); |
| 271 | - assert_eq!(team_color(id), team_color(id)); | |
| 272 | - assert!(PALETTE.contains(&team_color(id))); | |
| 273 | - assert_eq!(team_color(Uuid::from_u128(8)), PALETTE[8]); | |
| 290 | + assert_eq!(team_color(id, false), team_color(id, false)); | |
| 291 | + assert!(PALETTE.contains(&team_color(id, false))); | |
| 292 | + assert_eq!(team_color(Uuid::from_u128(8), false), PALETTE[8]); | |
| 293 | + assert_eq!(team_color(Uuid::from_u128(8), true), PALETTE_HC[8]); | |
| 274 | 294 | } |
| 275 | 295 | } |
| @@ -16,19 +16,34 @@ pub const PALETTE: [&str; 15] = [ | |||
| 16 | "#c98d8d", | 16 | "#c98d8d", |
| 17 | ]; | 17 | ]; |
| 18 | 18 | ||
| 19 | +/// Palette du thème contraste élevé — mêmes rangs que PALETTE, en niveaux de | ||
| 20 | +/// gris (les 7 premières valeurs viennent du projet Design, l'extension | ||
| 21 | +/// alterne foncé/clair pour garder des voisins distinguables). | ||
| 22 | +pub const PALETTE_HC: [&str; 15] = [ | ||
| 23 | + "#ffffff", "#9e9e9e", "#d6d6d6", "#6b6b6b", "#c0c0c0", "#828282", "#ebebeb", | ||
| 24 | + "#494949", "#b0b0b0", "#767676", "#f5f5f5", "#5a5a5a", "#cbcbcb", "#8f8f8f", | ||
| 25 | + "#e1e1e1", | ||
| 26 | +]; | ||
| 27 | + | ||
| 28 | +fn palette(hc: bool) -> &'static [&'static str; 15] { | ||
| 29 | + if hc { &PALETTE_HC } else { &PALETTE } | ||
| 30 | +} | ||
| 31 | + | ||
| 19 | /// category_id → couleur, à partir de la liste des tâches triée par id (création). | 32 | /// category_id → couleur, à partir de la liste des tâches triée par id (création). |
| 20 | -pub fn color_map(task_ids_in_creation_order: &[Uuid]) -> HashMap<Uuid, &'static str> { | 33 | +pub fn color_map(task_ids_in_creation_order: &[Uuid], hc: bool) -> HashMap<Uuid, &'static str> { |
| 34 | + let p = palette(hc); | ||
| 21 | task_ids_in_creation_order | 35 | task_ids_in_creation_order |
| 22 | .iter() | 36 | .iter() |
| 23 | .enumerate() | 37 | .enumerate() |
| 24 | - .map(|(i, id)| (*id, PALETTE[i % PALETTE.len()])) | 38 | + .map(|(i, id)| (*id, p[i % p.len()])) |
| 25 | .collect() | 39 | .collect() |
| 26 | } | 40 | } |
| 27 | 41 | ||
| 28 | /// Couleur d'une équipe — dérivée du hash de son id (les bits bas d'un | 42 | /// Couleur d'une équipe — dérivée du hash de son id (les bits bas d'un |
| 29 | /// UUIDv7 sont aléatoires), jamais stockée en base. | 43 | /// UUIDv7 sont aléatoires), jamais stockée en base. |
| 30 | -pub fn team_color(team_id: Uuid) -> &'static str { | 44 | +pub fn team_color(team_id: Uuid, hc: bool) -> &'static str { |
| 31 | - PALETTE[(team_id.as_u128() % PALETTE.len() as u128) as usize] | 45 | + let p = palette(hc); |
| 46 | + p[(team_id.as_u128() % p.len() as u128) as usize] | ||
| 32 | } | 47 | } |
| 33 | 48 | ||
| 34 | /// « 45 min », « 2 h », « 12 h 30 » | 49 | /// « 45 min », « 2 h », « 12 h 30 » |
| @@ -216,7 +231,7 @@ mod tests { | |||
| 216 | } | 231 | } |
| 217 | 232 | ||
| 218 | fn colors() -> HashMap<Uuid, &'static str> { | 233 | fn colors() -> HashMap<Uuid, &'static str> { |
| 219 | - color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)]) | 234 | + color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)], false) |
| 220 | } | 235 | } |
| 221 | 236 | ||
| 222 | #[test] | 237 | #[test] |
| @@ -260,16 +275,21 @@ mod tests { | |||
| 260 | #[test] | 275 | #[test] |
| 261 | fn stable_colors_by_creation_order() { | 276 | fn stable_colors_by_creation_order() { |
| 262 | let ids = [Uuid::from_u128(5), Uuid::from_u128(3)]; | 277 | let ids = [Uuid::from_u128(5), Uuid::from_u128(3)]; |
| 263 | - let m = color_map(&ids); | 278 | + let m = color_map(&ids, false); |
| 264 | assert_eq!(m[&ids[0]], PALETTE[0]); | 279 | assert_eq!(m[&ids[0]], PALETTE[0]); |
| 265 | assert_eq!(m[&ids[1]], PALETTE[1]); | 280 | assert_eq!(m[&ids[1]], PALETTE[1]); |
| 281 | + // Même rang dans les deux palettes : passer en HC ne mélange rien. | ||
| 282 | + let hc = color_map(&ids, true); | ||
| 283 | + assert_eq!(hc[&ids[0]], PALETTE_HC[0]); | ||
| 284 | + assert_eq!(hc[&ids[1]], PALETTE_HC[1]); | ||
| 266 | } | 285 | } |
| 267 | 286 | ||
| 268 | #[test] | 287 | #[test] |
| 269 | fn team_color_stable_and_in_palette() { | 288 | fn team_color_stable_and_in_palette() { |
| 270 | let id = Uuid::from_u128(0x1234_5678_9abc_def0); | 289 | let id = Uuid::from_u128(0x1234_5678_9abc_def0); |
| 271 | - assert_eq!(team_color(id), team_color(id)); | 290 | + assert_eq!(team_color(id, false), team_color(id, false)); |
| 272 | - assert!(PALETTE.contains(&team_color(id))); | 291 | + assert!(PALETTE.contains(&team_color(id, false))); |
| 273 | - assert_eq!(team_color(Uuid::from_u128(8)), PALETTE[8]); | 292 | + assert_eq!(team_color(Uuid::from_u128(8), false), PALETTE[8]); |
| 293 | + assert_eq!(team_color(Uuid::from_u128(8), true), PALETTE_HC[8]); | ||
| 274 | } | 294 | } |
| 275 | } | 295 | } |
modified
src/db/users.rs +13 -3 | @@ -37,9 +37,9 @@ pub async fn update_password(pool: &PgPool, id: Uuid, password_hash: &str) -> sq | ||
| 37 | 37 | Ok(()) |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | -/// (username, code langue) — profil minimal chargé à chaque requête. | |
| 41 | -pub async fn profile(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<(String, String)>> { | |
| 42 | - sqlx::query_as("SELECT username, lang FROM users WHERE id = $1") | |
| 40 | +/// (username, code langue, thème) — profil minimal chargé à chaque requête. | |
| 41 | +pub async fn profile(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<(String, String, String)>> { | |
| 42 | + sqlx::query_as("SELECT username, lang, theme FROM users WHERE id = $1") | |
| 43 | 43 | .bind(id) |
| 44 | 44 | .fetch_optional(pool) |
| 45 | 45 | .await |
| @@ -55,6 +55,16 @@ pub async fn set_lang(pool: &PgPool, id: Uuid, lang: &str) -> sqlx::Result<()> { | ||
| 55 | 55 | Ok(()) |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | +/// Le CHECK en base garde le garde-fou final ('organic' | 'hc'). | |
| 59 | +pub async fn set_theme(pool: &PgPool, id: Uuid, theme: &str) -> sqlx::Result<()> { | |
| 60 | + sqlx::query("UPDATE users SET theme = $1 WHERE id = $2") | |
| 61 | + .bind(theme) | |
| 62 | + .bind(id) | |
| 63 | + .execute(pool) | |
| 64 | + .await?; | |
| 65 | + Ok(()) | |
| 66 | +} | |
| 67 | + | |
| 58 | 68 | /// Cible d'une invitation. |
| 59 | 69 | pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> { |
| 60 | 70 | sqlx::query_as("SELECT id, username FROM users WHERE username = $1") |
| @@ -37,9 +37,9 @@ pub async fn update_password(pool: &PgPool, id: Uuid, password_hash: &str) -> sq | |||
| 37 | Ok(()) | 37 | Ok(()) |
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | -/// (username, code langue) — profil minimal chargé à chaque requête. | 40 | +/// (username, code langue, thème) — profil minimal chargé à chaque requête. |
| 41 | -pub async fn profile(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<(String, String)>> { | 41 | +pub async fn profile(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<(String, String, String)>> { |
| 42 | - sqlx::query_as("SELECT username, lang FROM users WHERE id = $1") | 42 | + sqlx::query_as("SELECT username, lang, theme FROM users WHERE id = $1") |
| 43 | .bind(id) | 43 | .bind(id) |
| 44 | .fetch_optional(pool) | 44 | .fetch_optional(pool) |
| 45 | .await | 45 | .await |
| @@ -55,6 +55,16 @@ pub async fn set_lang(pool: &PgPool, id: Uuid, lang: &str) -> sqlx::Result<()> { | |||
| 55 | Ok(()) | 55 | Ok(()) |
| 56 | } | 56 | } |
| 57 | 57 | ||
| 58 | +/// Le CHECK en base garde le garde-fou final ('organic' | 'hc'). | ||
| 59 | +pub async fn set_theme(pool: &PgPool, id: Uuid, theme: &str) -> sqlx::Result<()> { | ||
| 60 | + sqlx::query("UPDATE users SET theme = $1 WHERE id = $2") | ||
| 61 | + .bind(theme) | ||
| 62 | + .bind(id) | ||
| 63 | + .execute(pool) | ||
| 64 | + .await?; | ||
| 65 | + Ok(()) | ||
| 66 | +} | ||
| 67 | + | ||
| 58 | /// Cible d'une invitation. | 68 | /// Cible d'une invitation. |
| 59 | pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> { | 69 | pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> { |
| 60 | sqlx::query_as("SELECT id, username FROM users WHERE username = $1") | 70 | sqlx::query_as("SELECT id, username FROM users WHERE username = $1") |
modified
src/i18n.rs +28 -0 | @@ -194,6 +194,13 @@ pub struct Tr { | ||
| 194 | 194 | pub leave_team_pat: &'static str, |
| 195 | 195 | pub leave_note: &'static str, |
| 196 | 196 | pub language: &'static str, |
| 197 | + pub display: &'static str, | |
| 198 | + pub display_hint: &'static str, | |
| 199 | + pub theme_organic: &'static str, | |
| 200 | + pub theme_organic_desc: &'static str, | |
| 201 | + pub theme_hc: &'static str, | |
| 202 | + pub theme_hc_desc: &'static str, | |
| 203 | + pub toast_theme_updated: &'static str, | |
| 197 | 204 | pub session: &'static str, |
| 198 | 205 | pub current_password: &'static str, |
| 199 | 206 | pub new_password: &'static str, |
| @@ -429,6 +436,13 @@ pub static FR: Tr = Tr { | ||
| 429 | 436 | leave_team_pat: "Quitter « {} »", |
| 430 | 437 | leave_note: "Tes temps déjà saisis restent dans les stats de l'équipe.", |
| 431 | 438 | language: "Langue", |
| 439 | + display: "Affichage", | |
| 440 | + display_hint: "Le mode contraste élevé passe l'application en noir et blanc : lisible sur un écran très sombre ou avec un filtre de lumière chaude.", | |
| 441 | + theme_organic: "Organic", | |
| 442 | + theme_organic_desc: "Crème et terracotta, le style par défaut.", | |
| 443 | + theme_hc: "Contraste élevé", | |
| 444 | + theme_hc_desc: "Noir et blanc, lisible à très faible luminosité ou avec un filtre chaud.", | |
| 445 | + toast_theme_updated: "Affichage mis à jour", | |
| 432 | 446 | session: "Session", |
| 433 | 447 | current_password: "Mot de passe actuel", |
| 434 | 448 | new_password: "Nouveau mot de passe", |
| @@ -636,6 +650,13 @@ pub static EN: Tr = Tr { | ||
| 636 | 650 | leave_team_pat: "Leave “{}”", |
| 637 | 651 | leave_note: "Your recorded times stay in the team's stats.", |
| 638 | 652 | language: "Language", |
| 653 | + display: "Display", | |
| 654 | + display_hint: "High-contrast mode turns the app black and white: readable on a very dark screen or with a warm light filter.", | |
| 655 | + theme_organic: "Organic", | |
| 656 | + theme_organic_desc: "Cream and terracotta, the default style.", | |
| 657 | + theme_hc: "High contrast", | |
| 658 | + theme_hc_desc: "Black and white, readable in very low light or with a warm filter.", | |
| 659 | + toast_theme_updated: "Display updated", | |
| 639 | 660 | session: "Session", |
| 640 | 661 | current_password: "Current password", |
| 641 | 662 | new_password: "New password", |
| @@ -843,6 +864,13 @@ pub static DE: Tr = Tr { | ||
| 843 | 864 | leave_team_pat: "„{}“ verlassen", |
| 844 | 865 | leave_note: "Deine erfassten Zeiten bleiben in der Team-Statistik.", |
| 845 | 866 | language: "Sprache", |
| 867 | + display: "Anzeige", | |
| 868 | + display_hint: "Der Modus mit hohem Kontrast stellt die App in Schwarz-Weiß dar: gut lesbar auf einem sehr dunklen Bildschirm oder mit warmem Lichtfilter.", | |
| 869 | + theme_organic: "Organic", | |
| 870 | + theme_organic_desc: "Creme und Terrakotta, der Standard-Stil.", | |
| 871 | + theme_hc: "Hoher Kontrast", | |
| 872 | + theme_hc_desc: "Schwarz-Weiß, lesbar bei sehr wenig Licht oder mit warmem Filter.", | |
| 873 | + toast_theme_updated: "Anzeige aktualisiert", | |
| 846 | 874 | session: "Sitzung", |
| 847 | 875 | current_password: "Aktuelles Passwort", |
| 848 | 876 | new_password: "Neues Passwort", |
| @@ -194,6 +194,13 @@ pub struct Tr { | |||
| 194 | pub leave_team_pat: &'static str, | 194 | pub leave_team_pat: &'static str, |
| 195 | pub leave_note: &'static str, | 195 | pub leave_note: &'static str, |
| 196 | pub language: &'static str, | 196 | pub language: &'static str, |
| 197 | + pub display: &'static str, | ||
| 198 | + pub display_hint: &'static str, | ||
| 199 | + pub theme_organic: &'static str, | ||
| 200 | + pub theme_organic_desc: &'static str, | ||
| 201 | + pub theme_hc: &'static str, | ||
| 202 | + pub theme_hc_desc: &'static str, | ||
| 203 | + pub toast_theme_updated: &'static str, | ||
| 197 | pub session: &'static str, | 204 | pub session: &'static str, |
| 198 | pub current_password: &'static str, | 205 | pub current_password: &'static str, |
| 199 | pub new_password: &'static str, | 206 | pub new_password: &'static str, |
| @@ -429,6 +436,13 @@ pub static FR: Tr = Tr { | |||
| 429 | leave_team_pat: "Quitter « {} »", | 436 | leave_team_pat: "Quitter « {} »", |
| 430 | leave_note: "Tes temps déjà saisis restent dans les stats de l'équipe.", | 437 | leave_note: "Tes temps déjà saisis restent dans les stats de l'équipe.", |
| 431 | language: "Langue", | 438 | language: "Langue", |
| 439 | + display: "Affichage", | ||
| 440 | + display_hint: "Le mode contraste élevé passe l'application en noir et blanc : lisible sur un écran très sombre ou avec un filtre de lumière chaude.", | ||
| 441 | + theme_organic: "Organic", | ||
| 442 | + theme_organic_desc: "Crème et terracotta, le style par défaut.", | ||
| 443 | + theme_hc: "Contraste élevé", | ||
| 444 | + theme_hc_desc: "Noir et blanc, lisible à très faible luminosité ou avec un filtre chaud.", | ||
| 445 | + toast_theme_updated: "Affichage mis à jour", | ||
| 432 | session: "Session", | 446 | session: "Session", |
| 433 | current_password: "Mot de passe actuel", | 447 | current_password: "Mot de passe actuel", |
| 434 | new_password: "Nouveau mot de passe", | 448 | new_password: "Nouveau mot de passe", |
| @@ -636,6 +650,13 @@ pub static EN: Tr = Tr { | |||
| 636 | leave_team_pat: "Leave “{}”", | 650 | leave_team_pat: "Leave “{}”", |
| 637 | leave_note: "Your recorded times stay in the team's stats.", | 651 | leave_note: "Your recorded times stay in the team's stats.", |
| 638 | language: "Language", | 652 | language: "Language", |
| 653 | + display: "Display", | ||
| 654 | + display_hint: "High-contrast mode turns the app black and white: readable on a very dark screen or with a warm light filter.", | ||
| 655 | + theme_organic: "Organic", | ||
| 656 | + theme_organic_desc: "Cream and terracotta, the default style.", | ||
| 657 | + theme_hc: "High contrast", | ||
| 658 | + theme_hc_desc: "Black and white, readable in very low light or with a warm filter.", | ||
| 659 | + toast_theme_updated: "Display updated", | ||
| 639 | session: "Session", | 660 | session: "Session", |
| 640 | current_password: "Current password", | 661 | current_password: "Current password", |
| 641 | new_password: "New password", | 662 | new_password: "New password", |
| @@ -843,6 +864,13 @@ pub static DE: Tr = Tr { | |||
| 843 | leave_team_pat: "„{}“ verlassen", | 864 | leave_team_pat: "„{}“ verlassen", |
| 844 | leave_note: "Deine erfassten Zeiten bleiben in der Team-Statistik.", | 865 | leave_note: "Deine erfassten Zeiten bleiben in der Team-Statistik.", |
| 845 | language: "Sprache", | 866 | language: "Sprache", |
| 867 | + display: "Anzeige", | ||
| 868 | + display_hint: "Der Modus mit hohem Kontrast stellt die App in Schwarz-Weiß dar: gut lesbar auf einem sehr dunklen Bildschirm oder mit warmem Lichtfilter.", | ||
| 869 | + theme_organic: "Organic", | ||
| 870 | + theme_organic_desc: "Creme und Terrakotta, der Standard-Stil.", | ||
| 871 | + theme_hc: "Hoher Kontrast", | ||
| 872 | + theme_hc_desc: "Schwarz-Weiß, lesbar bei sehr wenig Licht oder mit warmem Filter.", | ||
| 873 | + toast_theme_updated: "Anzeige aktualisiert", | ||
| 846 | session: "Sitzung", | 874 | session: "Sitzung", |
| 847 | current_password: "Aktuelles Passwort", | 875 | current_password: "Aktuelles Passwort", |
| 848 | new_password: "Neues Passwort", | 876 | new_password: "Neues Passwort", |
modified
src/render.rs +2 -0 | @@ -53,6 +53,8 @@ pub struct Shell { | ||
| 53 | 53 | pub team_name: String, |
| 54 | 54 | /// Couleur de l'équipe active (teinte de la pill d'en-tête). |
| 55 | 55 | pub team_color: &'static str, |
| 56 | + /// Thème contraste élevé de l'utilisateur (attribut data-theme de <html>). | |
| 57 | + pub hc: bool, | |
| 56 | 58 | /// "add" | "stats" | "times" | "settings" |
| 57 | 59 | pub tab: &'static str, |
| 58 | 60 | pub toast: Option<String>, |
| @@ -53,6 +53,8 @@ pub struct Shell { | |||
| 53 | pub team_name: String, | 53 | pub team_name: String, |
| 54 | /// 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). |
| 55 | pub team_color: &'static str, | 55 | pub team_color: &'static str, |
| 56 | + /// Thème contraste élevé de l'utilisateur (attribut data-theme de <html>). | ||
| 57 | + pub hc: bool, | ||
| 56 | /// "add" | "stats" | "times" | "settings" | 58 | /// "add" | "stats" | "times" | "settings" |
| 57 | pub tab: &'static str, | 59 | pub tab: &'static str, |
| 58 | pub toast: Option<String>, | 60 | pub toast: Option<String>, |
modified
src/routes/entries.rs +2 -2 | @@ -121,7 +121,7 @@ pub async fn entry_new( | ||
| 121 | 121 | q.ok.map(|m| p1(tr.added_pat, fmt_minutes(m))) |
| 122 | 122 | }; |
| 123 | 123 | let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 124 | - 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<_>>(), ctx.hc); | |
| 125 | 125 | |
| 126 | 126 | let timer_row = timers::get(&state.pool, ctx.user.id).await?; |
| 127 | 127 | // Le bandeau « Lancer le minuteur » n'existe que sur cet écran, et |
| @@ -321,7 +321,7 @@ pub async fn entries_list( | ||
| 321 | 321 | entries::list_first(&state.pool, ctx.user.id, ctx.team_id, page * PAGE_SIZE).await?; |
| 322 | 322 | |
| 323 | 323 | let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 324 | - let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); | |
| 324 | + let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc); | |
| 325 | 325 | |
| 326 | 326 | let entries = rows |
| 327 | 327 | .into_iter() |
| @@ -121,7 +121,7 @@ pub async fn entry_new( | |||
| 121 | q.ok.map(|m| p1(tr.added_pat, fmt_minutes(m))) | 121 | q.ok.map(|m| p1(tr.added_pat, fmt_minutes(m))) |
| 122 | }; | 122 | }; |
| 123 | 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?; |
| 124 | - 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<_>>(), ctx.hc); |
| 125 | 125 | ||
| 126 | let timer_row = timers::get(&state.pool, ctx.user.id).await?; | 126 | let timer_row = timers::get(&state.pool, ctx.user.id).await?; |
| 127 | // Le bandeau « Lancer le minuteur » n'existe que sur cet écran, et | 127 | // Le bandeau « Lancer le minuteur » n'existe que sur cet écran, et |
| @@ -321,7 +321,7 @@ pub async fn entries_list( | |||
| 321 | entries::list_first(&state.pool, ctx.user.id, ctx.team_id, page * PAGE_SIZE).await?; | 321 | entries::list_first(&state.pool, ctx.user.id, ctx.team_id, page * PAGE_SIZE).await?; |
| 322 | 322 | ||
| 323 | let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?; | 323 | let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 324 | - let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); | 324 | + let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc); |
| 325 | 325 | ||
| 326 | let entries = rows | 326 | let entries = rows |
| 327 | .into_iter() | 327 | .into_iter() |
modified
src/routes/mod.rs +4 -2 | @@ -48,6 +48,7 @@ pub fn router(state: AppState) -> Router { | ||
| 48 | 48 | post(settings::password_change), |
| 49 | 49 | ) |
| 50 | 50 | .route("/parametres/langue", post(settings::lang_change)) |
| 51 | + .route("/parametres/affichage", post(settings::theme_change)) | |
| 51 | 52 | .route("/taches", get(todos::todos_page).post(todos::todo_create)) |
| 52 | 53 | .route("/taches/nouvelle", get(todos::todo_new)) |
| 53 | 54 | .route("/taches/{id}/modifier", get(todos::todo_edit)) |
| @@ -170,7 +171,8 @@ pub async fn shell( | ||
| 170 | 171 | lang_code: ctx.lang.code(), |
| 171 | 172 | tr, |
| 172 | 173 | team_name: ctx.team_name.clone(), |
| 173 | - team_color: team_color(ctx.team_id), | |
| 174 | + team_color: team_color(ctx.team_id, ctx.hc), | |
| 175 | + hc: ctx.hc, | |
| 174 | 176 | tab, |
| 175 | 177 | toast, |
| 176 | 178 | invite_count, |
| @@ -193,7 +195,7 @@ pub async fn shell( | ||
| 193 | 195 | .map(|t| TeamChoiceView { |
| 194 | 196 | current: t.id == ctx.team_id, |
| 195 | 197 | meta: tr.members_count(t.members), |
| 196 | - color: team_color(t.id), | |
| 198 | + color: team_color(t.id, ctx.hc), | |
| 197 | 199 | id: t.id, |
| 198 | 200 | name: t.name, |
| 199 | 201 | }) |
| @@ -48,6 +48,7 @@ pub fn router(state: AppState) -> Router { | |||
| 48 | post(settings::password_change), | 48 | post(settings::password_change), |
| 49 | ) | 49 | ) |
| 50 | .route("/parametres/langue", post(settings::lang_change)) | 50 | .route("/parametres/langue", post(settings::lang_change)) |
| 51 | + .route("/parametres/affichage", post(settings::theme_change)) | ||
| 51 | .route("/taches", get(todos::todos_page).post(todos::todo_create)) | 52 | .route("/taches", get(todos::todos_page).post(todos::todo_create)) |
| 52 | .route("/taches/nouvelle", get(todos::todo_new)) | 53 | .route("/taches/nouvelle", get(todos::todo_new)) |
| 53 | .route("/taches/{id}/modifier", get(todos::todo_edit)) | 54 | .route("/taches/{id}/modifier", get(todos::todo_edit)) |
| @@ -170,7 +171,8 @@ pub async fn shell( | |||
| 170 | lang_code: ctx.lang.code(), | 171 | lang_code: ctx.lang.code(), |
| 171 | tr, | 172 | tr, |
| 172 | team_name: ctx.team_name.clone(), | 173 | team_name: ctx.team_name.clone(), |
| 173 | - team_color: team_color(ctx.team_id), | 174 | + team_color: team_color(ctx.team_id, ctx.hc), |
| 175 | + hc: ctx.hc, | ||
| 174 | tab, | 176 | tab, |
| 175 | toast, | 177 | toast, |
| 176 | invite_count, | 178 | invite_count, |
| @@ -193,7 +195,7 @@ pub async fn shell( | |||
| 193 | .map(|t| TeamChoiceView { | 195 | .map(|t| TeamChoiceView { |
| 194 | current: t.id == ctx.team_id, | 196 | current: t.id == ctx.team_id, |
| 195 | meta: tr.members_count(t.members), | 197 | meta: tr.members_count(t.members), |
| 196 | - color: team_color(t.id), | 198 | + color: team_color(t.id, ctx.hc), |
| 197 | id: t.id, | 199 | id: t.id, |
| 198 | name: t.name, | 200 | name: t.name, |
| 199 | }) | 201 | }) |
modified
src/routes/settings.rs +49 -1 | @@ -43,6 +43,8 @@ pub struct SettingsPage { | ||
| 43 | 43 | pub invite_error: Option<String>, |
| 44 | 44 | /// Sélecteur de langue (par utilisateur, en base). |
| 45 | 45 | pub langs: Vec<LangView>, |
| 46 | + /// Sélecteur de thème (par utilisateur, en base — section Affichage). | |
| 47 | + pub themes: Vec<ThemeView>, | |
| 46 | 48 | pub members_title: String, |
| 47 | 49 | pub leave_label: String, |
| 48 | 50 | pub sent: Vec<SentView>, |
| @@ -73,6 +75,7 @@ pub async fn settings_page( | ||
| 73 | 75 | Some("eq-renommee") => Some(tr.toast_team_renamed.to_string()), |
| 74 | 76 | Some("quittee") => Some(tr.toast_team_left.to_string()), |
| 75 | 77 | Some("langue") => Some(tr.toast_lang_updated.to_string()), |
| 78 | + Some("affichage") => Some(tr.toast_theme_updated.to_string()), | |
| 76 | 79 | _ => match q.err.as_deref() { |
| 77 | 80 | Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()), |
| 78 | 81 | Some("eq-vide") => Some(tr.err_team_name_empty.to_string()), |
| @@ -132,11 +135,26 @@ pub async fn settings_page( | ||
| 132 | 135 | current: l == ctx.lang, |
| 133 | 136 | }) |
| 134 | 137 | .collect(); |
| 138 | + let themes = vec![ | |
| 139 | + ThemeView { | |
| 140 | + code: "organic", | |
| 141 | + name: tr.theme_organic, | |
| 142 | + desc: tr.theme_organic_desc, | |
| 143 | + current: !ctx.hc, | |
| 144 | + }, | |
| 145 | + ThemeView { | |
| 146 | + code: "hc", | |
| 147 | + name: tr.theme_hc, | |
| 148 | + desc: tr.theme_hc_desc, | |
| 149 | + current: ctx.hc, | |
| 150 | + }, | |
| 151 | + ]; | |
| 135 | 152 | Ok(HtmlTemplate(SettingsPage { |
| 136 | 153 | csrf: ctx.user.csrf_token(&state), |
| 137 | 154 | shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?, |
| 138 | 155 | error, |
| 139 | 156 | langs, |
| 157 | + themes, | |
| 140 | 158 | members_title: p1(tr.members_of_pat, &ctx.team_name), |
| 141 | 159 | leave_label: p1(tr.leave_team_pat, &ctx.team_name), |
| 142 | 160 | invites, |
| @@ -183,7 +201,7 @@ pub async fn categories_page( | ||
| 183 | 201 | _ => None, |
| 184 | 202 | }; |
| 185 | 203 | let by_creation = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 186 | - let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>()); | |
| 204 | + let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc); | |
| 187 | 205 | let mut list = by_creation; |
| 188 | 206 | list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name))); |
| 189 | 207 | let categories = list |
| @@ -216,12 +234,25 @@ pub struct LangView { | ||
| 216 | 234 | pub current: bool, |
| 217 | 235 | } |
| 218 | 236 | |
| 237 | +pub struct ThemeView { | |
| 238 | + pub code: &'static str, | |
| 239 | + pub name: &'static str, | |
| 240 | + pub desc: &'static str, | |
| 241 | + pub current: bool, | |
| 242 | +} | |
| 243 | + | |
| 219 | 244 | #[derive(Deserialize)] |
| 220 | 245 | pub struct LangForm { |
| 221 | 246 | langue: String, |
| 222 | 247 | csrf: String, |
| 223 | 248 | } |
| 224 | 249 | |
| 250 | +#[derive(Deserialize)] | |
| 251 | +pub struct ThemeForm { | |
| 252 | + theme: String, | |
| 253 | + csrf: String, | |
| 254 | +} | |
| 255 | + | |
| 225 | 256 | /// POST /parametres/langue — choix PAR UTILISATEUR, enregistré en base |
| 226 | 257 | /// (jamais par navigateur : le réglage suit le compte partout). |
| 227 | 258 | pub async fn lang_change( |
| @@ -239,6 +270,23 @@ pub async fn lang_change( | ||
| 239 | 270 | Ok(Redirect::to("/parametres?ok=langue")) |
| 240 | 271 | } |
| 241 | 272 | |
| 273 | +/// POST /parametres/affichage — thème PAR UTILISATEUR, enregistré en base | |
| 274 | +/// (comme la langue : le réglage suit le compte partout). | |
| 275 | +pub async fn theme_change( | |
| 276 | + State(state): State<AppState>, | |
| 277 | + ctx: ActiveTeam, | |
| 278 | + Form(form): Form<ThemeForm>, | |
| 279 | +) -> Result<Redirect, AppError> { | |
| 280 | + if !ctx.user.check_csrf(&state, &form.csrf) { | |
| 281 | + return Err(AppError::Csrf); | |
| 282 | + } | |
| 283 | + if !matches!(form.theme.as_str(), "organic" | "hc") { | |
| 284 | + return Err(AppError::BadRequest("bad theme".into())); | |
| 285 | + } | |
| 286 | + users::set_theme(&state.pool, ctx.user.id, &form.theme).await?; | |
| 287 | + Ok(Redirect::to("/parametres?ok=affichage")) | |
| 288 | +} | |
| 289 | + | |
| 242 | 290 | fn is_unique_violation(e: &sqlx::Error) -> bool { |
| 243 | 291 | matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) |
| 244 | 292 | } |
| @@ -43,6 +43,8 @@ pub struct SettingsPage { | |||
| 43 | pub invite_error: Option<String>, | 43 | pub invite_error: Option<String>, |
| 44 | /// Sélecteur de langue (par utilisateur, en base). | 44 | /// Sélecteur de langue (par utilisateur, en base). |
| 45 | pub langs: Vec<LangView>, | 45 | pub langs: Vec<LangView>, |
| 46 | + /// Sélecteur de thème (par utilisateur, en base — section Affichage). | ||
| 47 | + pub themes: Vec<ThemeView>, | ||
| 46 | pub members_title: String, | 48 | pub members_title: String, |
| 47 | pub leave_label: String, | 49 | pub leave_label: String, |
| 48 | pub sent: Vec<SentView>, | 50 | pub sent: Vec<SentView>, |
| @@ -73,6 +75,7 @@ pub async fn settings_page( | |||
| 73 | Some("eq-renommee") => Some(tr.toast_team_renamed.to_string()), | 75 | Some("eq-renommee") => Some(tr.toast_team_renamed.to_string()), |
| 74 | Some("quittee") => Some(tr.toast_team_left.to_string()), | 76 | Some("quittee") => Some(tr.toast_team_left.to_string()), |
| 75 | Some("langue") => Some(tr.toast_lang_updated.to_string()), | 77 | Some("langue") => Some(tr.toast_lang_updated.to_string()), |
| 78 | + Some("affichage") => Some(tr.toast_theme_updated.to_string()), | ||
| 76 | _ => match q.err.as_deref() { | 79 | _ => match q.err.as_deref() { |
| 77 | Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()), | 80 | Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()), |
| 78 | Some("eq-vide") => Some(tr.err_team_name_empty.to_string()), | 81 | Some("eq-vide") => Some(tr.err_team_name_empty.to_string()), |
| @@ -132,11 +135,26 @@ pub async fn settings_page( | |||
| 132 | current: l == ctx.lang, | 135 | current: l == ctx.lang, |
| 133 | }) | 136 | }) |
| 134 | .collect(); | 137 | .collect(); |
| 138 | + let themes = vec![ | ||
| 139 | + ThemeView { | ||
| 140 | + code: "organic", | ||
| 141 | + name: tr.theme_organic, | ||
| 142 | + desc: tr.theme_organic_desc, | ||
| 143 | + current: !ctx.hc, | ||
| 144 | + }, | ||
| 145 | + ThemeView { | ||
| 146 | + code: "hc", | ||
| 147 | + name: tr.theme_hc, | ||
| 148 | + desc: tr.theme_hc_desc, | ||
| 149 | + current: ctx.hc, | ||
| 150 | + }, | ||
| 151 | + ]; | ||
| 135 | Ok(HtmlTemplate(SettingsPage { | 152 | Ok(HtmlTemplate(SettingsPage { |
| 136 | csrf: ctx.user.csrf_token(&state), | 153 | csrf: ctx.user.csrf_token(&state), |
| 137 | shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?, | 154 | shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?, |
| 138 | error, | 155 | error, |
| 139 | langs, | 156 | langs, |
| 157 | + themes, | ||
| 140 | members_title: p1(tr.members_of_pat, &ctx.team_name), | 158 | members_title: p1(tr.members_of_pat, &ctx.team_name), |
| 141 | leave_label: p1(tr.leave_team_pat, &ctx.team_name), | 159 | leave_label: p1(tr.leave_team_pat, &ctx.team_name), |
| 142 | invites, | 160 | invites, |
| @@ -183,7 +201,7 @@ pub async fn categories_page( | |||
| 183 | _ => None, | 201 | _ => None, |
| 184 | }; | 202 | }; |
| 185 | let by_creation = categories::list_by_creation(&state.pool, ctx.team_id).await?; | 203 | let by_creation = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 186 | - let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>()); | 204 | + let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc); |
| 187 | let mut list = by_creation; | 205 | let mut list = by_creation; |
| 188 | list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name))); | 206 | list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name))); |
| 189 | let categories = list | 207 | let categories = list |
| @@ -216,12 +234,25 @@ pub struct LangView { | |||
| 216 | pub current: bool, | 234 | pub current: bool, |
| 217 | } | 235 | } |
| 218 | 236 | ||
| 237 | +pub struct ThemeView { | ||
| 238 | + pub code: &'static str, | ||
| 239 | + pub name: &'static str, | ||
| 240 | + pub desc: &'static str, | ||
| 241 | + pub current: bool, | ||
| 242 | +} | ||
| 243 | + | ||
| 219 | #[derive(Deserialize)] | 244 | #[derive(Deserialize)] |
| 220 | pub struct LangForm { | 245 | pub struct LangForm { |
| 221 | langue: String, | 246 | langue: String, |
| 222 | csrf: String, | 247 | csrf: String, |
| 223 | } | 248 | } |
| 224 | 249 | ||
| 250 | +#[derive(Deserialize)] | ||
| 251 | +pub struct ThemeForm { | ||
| 252 | + theme: String, | ||
| 253 | + csrf: String, | ||
| 254 | +} | ||
| 255 | + | ||
| 225 | /// POST /parametres/langue — choix PAR UTILISATEUR, enregistré en base | 256 | /// POST /parametres/langue — choix PAR UTILISATEUR, enregistré en base |
| 226 | /// (jamais par navigateur : le réglage suit le compte partout). | 257 | /// (jamais par navigateur : le réglage suit le compte partout). |
| 227 | pub async fn lang_change( | 258 | pub async fn lang_change( |
| @@ -239,6 +270,23 @@ pub async fn lang_change( | |||
| 239 | Ok(Redirect::to("/parametres?ok=langue")) | 270 | Ok(Redirect::to("/parametres?ok=langue")) |
| 240 | } | 271 | } |
| 241 | 272 | ||
| 273 | +/// POST /parametres/affichage — thème PAR UTILISATEUR, enregistré en base | ||
| 274 | +/// (comme la langue : le réglage suit le compte partout). | ||
| 275 | +pub async fn theme_change( | ||
| 276 | + State(state): State<AppState>, | ||
| 277 | + ctx: ActiveTeam, | ||
| 278 | + Form(form): Form<ThemeForm>, | ||
| 279 | +) -> Result<Redirect, AppError> { | ||
| 280 | + if !ctx.user.check_csrf(&state, &form.csrf) { | ||
| 281 | + return Err(AppError::Csrf); | ||
| 282 | + } | ||
| 283 | + if !matches!(form.theme.as_str(), "organic" | "hc") { | ||
| 284 | + return Err(AppError::BadRequest("bad theme".into())); | ||
| 285 | + } | ||
| 286 | + users::set_theme(&state.pool, ctx.user.id, &form.theme).await?; | ||
| 287 | + Ok(Redirect::to("/parametres?ok=affichage")) | ||
| 288 | +} | ||
| 289 | + | ||
| 242 | fn is_unique_violation(e: &sqlx::Error) -> bool { | 290 | fn is_unique_violation(e: &sqlx::Error) -> bool { |
| 243 | matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) | 291 | matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) |
| 244 | } | 292 | } |
modified
src/routes/stats.rs +1 -1 | @@ -61,7 +61,7 @@ pub async fn stats_page( | ||
| 61 | 61 | let (year_from, year_to) = year.bounds(); |
| 62 | 62 | |
| 63 | 63 | let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 64 | - let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); | |
| 64 | + let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc); | |
| 65 | 65 | |
| 66 | 66 | let week_rows = stats::totals(&state.pool, ctx.team_id, Some(week_from), Some(week_to)).await?; |
| 67 | 67 | let month_rows = |
| @@ -61,7 +61,7 @@ pub async fn stats_page( | |||
| 61 | let (year_from, year_to) = year.bounds(); | 61 | let (year_from, year_to) = year.bounds(); |
| 62 | 62 | ||
| 63 | let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?; | 63 | let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 64 | - let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); | 64 | + let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc); |
| 65 | 65 | ||
| 66 | let week_rows = stats::totals(&state.pool, ctx.team_id, Some(week_from), Some(week_to)).await?; | 66 | let week_rows = stats::totals(&state.pool, ctx.team_id, Some(week_from), Some(week_to)).await?; |
| 67 | let month_rows = | 67 | let month_rows = |
modified
src/routes/todos.rs +3 -3 | @@ -120,7 +120,7 @@ pub async fn todos_page( | ||
| 120 | 120 | by_todo.entry(a.todo_id).or_default().push((a.user_id, a.username)); |
| 121 | 121 | } |
| 122 | 122 | let all_categories = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 123 | - let colors = color_map(&all_categories.iter().map(|c| c.id).collect::<Vec<_>>()); | |
| 123 | + let colors = color_map(&all_categories.iter().map(|c| c.id).collect::<Vec<_>>(), ctx.hc); | |
| 124 | 124 | |
| 125 | 125 | let card = |t: &TodoRow| { |
| 126 | 126 | let assignees = by_todo.get(&t.id).map(Vec::as_slice).unwrap_or(&[]); |
| @@ -275,7 +275,7 @@ async fn form_page( | ||
| 275 | 275 | err: Option<&str>, |
| 276 | 276 | ) -> Result<HtmlTemplate<TodoFormPage>, AppError> { |
| 277 | 277 | let all = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 278 | - 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<_>>(), ctx.hc); | |
| 279 | 279 | let date = todo.as_ref().map(|t| t.activation_date).unwrap_or_else(paris_today); |
| 280 | 280 | let selected_cat = todo.as_ref().map(|t| t.category_id); |
| 281 | 281 | let assigned = assigned.unwrap_or_default(); |
| @@ -509,7 +509,7 @@ pub async fn todo_done_page( | ||
| 509 | 509 | today, |
| 510 | 510 | ); |
| 511 | 511 | let all = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 512 | - let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>()); | |
| 512 | + let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>(), ctx.hc); | |
| 513 | 513 | |
| 514 | 514 | let minutes = q.minutes.unwrap_or(30).max(1); |
| 515 | 515 | let date = q.date.unwrap_or(today).min(today); |
| @@ -120,7 +120,7 @@ pub async fn todos_page( | |||
| 120 | by_todo.entry(a.todo_id).or_default().push((a.user_id, a.username)); | 120 | by_todo.entry(a.todo_id).or_default().push((a.user_id, a.username)); |
| 121 | } | 121 | } |
| 122 | let all_categories = categories::list_by_creation(&state.pool, ctx.team_id).await?; | 122 | let all_categories = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 123 | - let colors = color_map(&all_categories.iter().map(|c| c.id).collect::<Vec<_>>()); | 123 | + let colors = color_map(&all_categories.iter().map(|c| c.id).collect::<Vec<_>>(), ctx.hc); |
| 124 | 124 | ||
| 125 | let card = |t: &TodoRow| { | 125 | let card = |t: &TodoRow| { |
| 126 | let assignees = by_todo.get(&t.id).map(Vec::as_slice).unwrap_or(&[]); | 126 | let assignees = by_todo.get(&t.id).map(Vec::as_slice).unwrap_or(&[]); |
| @@ -275,7 +275,7 @@ async fn form_page( | |||
| 275 | err: Option<&str>, | 275 | err: Option<&str>, |
| 276 | ) -> Result<HtmlTemplate<TodoFormPage>, AppError> { | 276 | ) -> Result<HtmlTemplate<TodoFormPage>, AppError> { |
| 277 | 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?; |
| 278 | - 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<_>>(), ctx.hc); |
| 279 | 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); |
| 280 | let selected_cat = todo.as_ref().map(|t| t.category_id); | 280 | let selected_cat = todo.as_ref().map(|t| t.category_id); |
| 281 | let assigned = assigned.unwrap_or_default(); | 281 | let assigned = assigned.unwrap_or_default(); |
| @@ -509,7 +509,7 @@ pub async fn todo_done_page( | |||
| 509 | today, | 509 | today, |
| 510 | ); | 510 | ); |
| 511 | let all = categories::list_by_creation(&state.pool, ctx.team_id).await?; | 511 | let all = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 512 | - let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>()); | 512 | + let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>(), ctx.hc); |
| 513 | 513 | ||
| 514 | let minutes = q.minutes.unwrap_or(30).max(1); | 514 | let minutes = q.minutes.unwrap_or(30).max(1); |
| 515 | let date = q.date.unwrap_or(today).min(today); | 515 | let date = q.date.unwrap_or(today).min(today); |
modified
src/team.rs +6 -1 | @@ -40,6 +40,8 @@ pub struct ActiveTeam { | ||
| 40 | 40 | pub username: String, |
| 41 | 41 | /// Langue de l'interface — par utilisateur, stockée en base. |
| 42 | 42 | pub lang: Lang, |
| 43 | + /// Thème contraste élevé (users.theme = 'hc') — par utilisateur, en base. | |
| 44 | + pub hc: bool, | |
| 43 | 45 | pub team_id: Uuid, |
| 44 | 46 | pub team_name: String, |
| 45 | 47 | } |
| @@ -78,10 +80,11 @@ impl FromRequestParts<AppState> for ActiveTeam { | ||
| 78 | 80 | state: &AppState, |
| 79 | 81 | ) -> Result<Self, TeamRejection> { |
| 80 | 82 | let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?; |
| 81 | - let (username, lang_code) = users::profile(&state.pool, user.id) | |
| 83 | + let (username, lang_code, theme) = users::profile(&state.pool, user.id) | |
| 82 | 84 | .await? |
| 83 | 85 | .unwrap_or_default(); |
| 84 | 86 | let lang = Lang::from_code(&lang_code).unwrap_or(Lang::Fr); |
| 87 | + let hc = theme == "hc"; | |
| 85 | 88 | let jar = CookieJar::from_headers(&parts.headers); |
| 86 | 89 | let wanted = jar |
| 87 | 90 | .get(TEAM_COOKIE) |
| @@ -93,6 +96,7 @@ impl FromRequestParts<AppState> for ActiveTeam { | ||
| 93 | 96 | user, |
| 94 | 97 | username, |
| 95 | 98 | lang, |
| 99 | + hc, | |
| 96 | 100 | team_id: id, |
| 97 | 101 | team_name: name, |
| 98 | 102 | }); |
| @@ -103,6 +107,7 @@ impl FromRequestParts<AppState> for ActiveTeam { | ||
| 103 | 107 | user, |
| 104 | 108 | username, |
| 105 | 109 | lang, |
| 110 | + hc, | |
| 106 | 111 | team_id: id, |
| 107 | 112 | team_name: name, |
| 108 | 113 | }), |
| @@ -40,6 +40,8 @@ pub struct ActiveTeam { | |||
| 40 | pub username: String, | 40 | pub username: String, |
| 41 | /// Langue de l'interface — par utilisateur, stockée en base. | 41 | /// Langue de l'interface — par utilisateur, stockée en base. |
| 42 | pub lang: Lang, | 42 | pub lang: Lang, |
| 43 | + /// Thème contraste élevé (users.theme = 'hc') — par utilisateur, en base. | ||
| 44 | + pub hc: bool, | ||
| 43 | pub team_id: Uuid, | 45 | pub team_id: Uuid, |
| 44 | pub team_name: String, | 46 | pub team_name: String, |
| 45 | } | 47 | } |
| @@ -78,10 +80,11 @@ impl FromRequestParts<AppState> for ActiveTeam { | |||
| 78 | state: &AppState, | 80 | state: &AppState, |
| 79 | ) -> Result<Self, TeamRejection> { | 81 | ) -> Result<Self, TeamRejection> { |
| 80 | let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?; | 82 | let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?; |
| 81 | - let (username, lang_code) = users::profile(&state.pool, user.id) | 83 | + let (username, lang_code, theme) = users::profile(&state.pool, user.id) |
| 82 | .await? | 84 | .await? |
| 83 | .unwrap_or_default(); | 85 | .unwrap_or_default(); |
| 84 | let lang = Lang::from_code(&lang_code).unwrap_or(Lang::Fr); | 86 | let lang = Lang::from_code(&lang_code).unwrap_or(Lang::Fr); |
| 87 | + let hc = theme == "hc"; | ||
| 85 | let jar = CookieJar::from_headers(&parts.headers); | 88 | let jar = CookieJar::from_headers(&parts.headers); |
| 86 | let wanted = jar | 89 | let wanted = jar |
| 87 | .get(TEAM_COOKIE) | 90 | .get(TEAM_COOKIE) |
| @@ -93,6 +96,7 @@ impl FromRequestParts<AppState> for ActiveTeam { | |||
| 93 | user, | 96 | user, |
| 94 | username, | 97 | username, |
| 95 | lang, | 98 | lang, |
| 99 | + hc, | ||
| 96 | team_id: id, | 100 | team_id: id, |
| 97 | team_name: name, | 101 | team_name: name, |
| 98 | }); | 102 | }); |
| @@ -103,6 +107,7 @@ impl FromRequestParts<AppState> for ActiveTeam { | |||
| 103 | user, | 107 | user, |
| 104 | username, | 108 | username, |
| 105 | lang, | 109 | lang, |
| 110 | + hc, | ||
| 106 | team_id: id, | 111 | team_id: id, |
| 107 | team_name: name, | 112 | team_name: name, |
| 108 | }), | 113 | }), |
modified
templates/app.html +2 -0 | @@ -1,6 +1,8 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | 2 | {% block title %}{{ shell.title }} · Ex Ego{% endblock %} |
| 3 | 3 | {% block lang %}{{ shell.lang_code }}{% endblock %} |
| 4 | +{% block themeattr %}{% if shell.hc %} data-theme="hc"{% endif %}{% endblock %} | |
| 5 | +{% block themecolor %}{% if shell.hc %}#000000{% else %}#f5ead8{% endif %}{% endblock %} | |
| 4 | 6 | {% block body %} |
| 5 | 7 | <header class="app-header"> |
| 6 | 8 | <div class="app-topline"> |
| @@ -1,6 +1,8 @@ | |||
| 1 | {% extends "base.html" %} | 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ shell.title }} · Ex Ego{% endblock %} | 2 | {% block title %}{{ shell.title }} · Ex Ego{% endblock %} |
| 3 | {% block lang %}{{ shell.lang_code }}{% endblock %} | 3 | {% block lang %}{{ shell.lang_code }}{% endblock %} |
| 4 | +{% block themeattr %}{% if shell.hc %} data-theme="hc"{% endif %}{% endblock %} | ||
| 5 | +{% block themecolor %}{% if shell.hc %}#000000{% else %}#f5ead8{% endif %}{% endblock %} | ||
| 4 | {% block body %} | 6 | {% block body %} |
| 5 | <header class="app-header"> | 7 | <header class="app-header"> |
| 6 | <div class="app-topline"> | 8 | <div class="app-topline"> |
modified
templates/base.html +2 -2 | @@ -1,9 +1,9 @@ | ||
| 1 | 1 | <!doctype html> |
| 2 | -<html lang="{% block lang %}fr{% endblock %}"> | |
| 2 | +<html lang="{% block lang %}fr{% endblock %}"{% block themeattr %}{% endblock %}> | |
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="utf-8"> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> |
| 6 | -<meta name="theme-color" content="#f5ead8"> | |
| 6 | +<meta name="theme-color" content="{% block themecolor %}#f5ead8{% endblock %}"> | |
| 7 | 7 | <meta name="apple-mobile-web-app-title" content="Ex Ego"> |
| 8 | 8 | <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> |
| 9 | 9 | <link rel="manifest" href="{{ crate::routes::statics::manifest_href() }}"> |
| @@ -1,9 +1,9 @@ | |||
| 1 | <!doctype html> | 1 | <!doctype html> |
| 2 | -<html lang="{% block lang %}fr{% endblock %}"> | 2 | +<html lang="{% block lang %}fr{% endblock %}"{% block themeattr %}{% 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"> |
| 6 | -<meta name="theme-color" content="#f5ead8"> | 6 | +<meta name="theme-color" content="{% block themecolor %}#f5ead8{% endblock %}"> |
| 7 | <meta name="apple-mobile-web-app-title" content="Ex Ego"> | 7 | <meta name="apple-mobile-web-app-title" content="Ex Ego"> |
| 8 | <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> | 8 | <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> |
| 9 | <link rel="manifest" href="{{ crate::routes::statics::manifest_href() }}"> | 9 | <link rel="manifest" href="{{ crate::routes::statics::manifest_href() }}"> |
modified
templates/settings.html +19 -0 | @@ -113,6 +113,25 @@ | ||
| 113 | 113 | {% endfor %} |
| 114 | 114 | </div> |
| 115 | 115 | |
| 116 | +<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-2)">{{ shell.tr.display }}</h2> | |
| 117 | +<p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ shell.tr.display_hint }}</p> | |
| 118 | +<div style="display:grid;gap:var(--space-2);margin-bottom:var(--space-2)"> | |
| 119 | + {% for th in themes %} | |
| 120 | + <form method="post" action="/parametres/affichage" style="margin:0"> | |
| 121 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | |
| 122 | + <input type="hidden" name="theme" value="{{ th.code }}"> | |
| 123 | + <button type="submit" class="theme-choice{% if th.current %} current{% endif %}" {% if th.current %}aria-current="true"{% endif %}> | |
| 124 | + <span class="theme-swatch swatch-{{ th.code }}"></span> | |
| 125 | + <span style="flex:1;text-align:left"> | |
| 126 | + <span style="display:block;font-size:16px">{{ th.name }}</span> | |
| 127 | + <span style="display:block;font-size:12px;opacity:.75">{{ th.desc }}</span> | |
| 128 | + </span> | |
| 129 | + {% if th.current %}<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="M20 6L9 17l-5-5"></path></svg>{% endif %} | |
| 130 | + </button> | |
| 131 | + </form> | |
| 132 | + {% endfor %} | |
| 133 | +</div> | |
| 134 | + | |
| 116 | 135 | <h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2> |
| 117 | 136 | <form method="post" action="/parametres/mot-de-passe"> |
| 118 | 137 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| @@ -113,6 +113,25 @@ | |||
| 113 | {% endfor %} | 113 | {% endfor %} |
| 114 | </div> | 114 | </div> |
| 115 | 115 | ||
| 116 | +<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-2)">{{ shell.tr.display }}</h2> | ||
| 117 | +<p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ shell.tr.display_hint }}</p> | ||
| 118 | +<div style="display:grid;gap:var(--space-2);margin-bottom:var(--space-2)"> | ||
| 119 | + {% for th in themes %} | ||
| 120 | + <form method="post" action="/parametres/affichage" style="margin:0"> | ||
| 121 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | ||
| 122 | + <input type="hidden" name="theme" value="{{ th.code }}"> | ||
| 123 | + <button type="submit" class="theme-choice{% if th.current %} current{% endif %}" {% if th.current %}aria-current="true"{% endif %}> | ||
| 124 | + <span class="theme-swatch swatch-{{ th.code }}"></span> | ||
| 125 | + <span style="flex:1;text-align:left"> | ||
| 126 | + <span style="display:block;font-size:16px">{{ th.name }}</span> | ||
| 127 | + <span style="display:block;font-size:12px;opacity:.75">{{ th.desc }}</span> | ||
| 128 | + </span> | ||
| 129 | + {% if th.current %}<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="M20 6L9 17l-5-5"></path></svg>{% endif %} | ||
| 130 | + </button> | ||
| 131 | + </form> | ||
| 132 | + {% endfor %} | ||
| 133 | +</div> | ||
| 134 | + | ||
| 116 | <h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2> | 135 | <h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2> |
| 117 | <form method="post" action="/parametres/mot-de-passe"> | 136 | <form method="post" action="/parametres/mot-de-passe"> |
| 118 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 137 | <input type="hidden" name="csrf" value="{{ csrf }}"> |