diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 639bb13..e20c749 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -22,11 +22,11 @@ const PENDING_RESTORE = DB_PATH + '.pending-restore'; // générées côté dev (api_keys, y compris les clés internes auto-provisionnées // pour l'assistant IA), écrase la configuration des fournisseurs IA propres // à dev (ai_providers/ai_config) par celle de la source restaurée, et -// écraserait de la même façon l'étiquette d'environnement de cette instance -// (parametres_environnement — Admin > Général, cf. 05/09/26) par celle de la -// source restaurée. Ordre important : ai_config référence ai_providers (FK -// provider_defaut_id). -const ENV_LOCAL_TABLES = ['api_keys', 'ai_providers', 'ai_config', 'parametres_environnement']; +// écraserait de la même façon l'étiquette d'environnement (parametres_environnement) +// et le thème de couleur (parametres_theme — Admin > Général, cf. 05/09/26) +// de cette instance par ceux de la source restaurée. Ordre important : +// ai_config référence ai_providers (FK provider_defaut_id). +const ENV_LOCAL_TABLES = ['api_keys', 'ai_providers', 'ai_config', 'parametres_environnement', 'parametres_theme']; let pendingRestorePreserve = null; if (fs.existsSync(PENDING_RESTORE) && fs.existsSync(DB_PATH)) { try { @@ -42,7 +42,7 @@ if (fs.existsSync(PENDING_RESTORE) && fs.existsSync(DB_PATH)) { } oldDb.close(); } catch (e) { - console.error('[restore] Échec de la sauvegarde des tables locales (api_keys/ai_providers/ai_config/parametres_environnement) avant restauration :', e.message); + console.error('[restore] Échec de la sauvegarde des tables locales (api_keys/ai_providers/ai_config/parametres_environnement/parametres_theme) avant restauration :', e.message); pendingRestorePreserve = null; } } @@ -1847,6 +1847,72 @@ db.exec(` `); db.exec(`INSERT OR IGNORE INTO parametres_environnement (id) VALUES (1)`); +// ── Migration : thème de couleur de l'interface (Admin > Général) ─────────── +// Demande Olivier 05/09/26 : teinte de l'interface (menus, boutons — distincte +// du mode clair/sombre existant, cf. ThemeContext.jsx) choisie parmi une +// liste de préréglages (frontend/src/styles.css, blocs [data-color-theme]), +// appliquée à TOUS les utilisateurs (contrairement au mode clair/sombre, une +// préférence par utilisateur). But d'Olivier : garder Production dans le +// bleu par défaut mais donner aux autres environnements une couleur +// différente, reconnaissable au premier coup d'œil. Table dédiée (même +// raisonnement que parametres_environnement ci-dessus) et ajoutée à +// ENV_LOCAL_TABLES : ne doit jamais être réécrite par une restauration. +// +// 05/09/26 (suite, même jour) : les 5 thèmes initiaux (bleu/vert/violet/ +// orange/bordeaux, couleurs choisies librement) sont remplacés par 10, +// alignés sur les 10 palettes de graphiques de Settings > Apparence +// (cf. project_chart_colors.md) — chaque thème reprend la TEINTE de la +// couleur "Intérêts" de la palette correspondante (même méthode de rotation +// que les 5 thèmes initiaux : S/L de chaque jeton CSS inchangés, seule la +// teinte tourne). SQLite ne permet pas d'altérer un CHECK constraint : la +// table doit être recréée, même stratégie que les migrations perte_definitive/ +// prolongation sur investissements plus loin dans ce fichier. Valeur existante +// migrée : 'violet' reste 'violet' (même nom, teinte recalculée depuis la +// palette) ; tout le reste (bleu/vert/orange/bordeaux) retombe sur 'classique' +// — décision explicite d'Olivier pour son propre 'bordeaux' actif, généralisée +// à toute valeur historique sans successeur direct parmi les 10 palettes. +{ + const NEW_COLOR_THEMES = ['classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil', 'violet', 'frais', 'contraste', 'pastel', 'moderne']; + const schemaTheme = db.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='parametres_theme'" + ).get()?.sql ?? ''; + + if (schemaTheme && !schemaTheme.includes("'classique'")) { + const oldValue = db.prepare('SELECT color_theme FROM parametres_theme WHERE id = 1').get()?.color_theme; + const migratedValue = NEW_COLOR_THEMES.includes(oldValue) ? oldValue : 'classique'; + const tempName = '__repair_parametres_theme'; + + db.exec('PRAGMA foreign_keys = OFF'); + db.exec(`DROP TABLE IF EXISTS "${tempName}"`); + db.exec(` + CREATE TABLE "${tempName}" ( + id INTEGER PRIMARY KEY CHECK (id = 1), + color_theme TEXT NOT NULL DEFAULT 'classique' + CHECK (color_theme IN ('classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil', 'violet', 'frais', 'contraste', 'pastel', 'moderne')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + db.prepare(`INSERT INTO "${tempName}" (id, color_theme) VALUES (1, ?)`).run(migratedValue); + db.exec('DROP TABLE parametres_theme'); + db.exec('PRAGMA legacy_alter_table = ON'); + db.exec(`ALTER TABLE "${tempName}" RENAME TO parametres_theme`); + db.exec('PRAGMA legacy_alter_table = OFF'); + db.exec('PRAGMA foreign_keys = ON'); + + console.log(`[DB] parametres_theme : migré vers les 10 thèmes basés sur les palettes de graphiques (valeur existante '${oldValue}' → '${migratedValue}')`); + } else { + db.exec(` + CREATE TABLE IF NOT EXISTS parametres_theme ( + id INTEGER PRIMARY KEY CHECK (id = 1), + color_theme TEXT NOT NULL DEFAULT 'classique' + CHECK (color_theme IN ('classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil', 'violet', 'frais', 'contraste', 'pastel', 'moderne')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + } +} +db.exec(`INSERT OR IGNORE INTO parametres_theme (id) VALUES (1)`); + // ── Migration : email_verified sur users ───────────────────────────────────── { const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); @@ -2881,7 +2947,7 @@ if (pendingRestorePreserve) { } }); restoreEnvLocal(); - console.log('[restore] Tables locales (api_keys/ai_providers/ai_config/parametres_environnement) préservées et réappliquées après restauration.'); + console.log('[restore] Tables locales (api_keys/ai_providers/ai_config/parametres_environnement/parametres_theme) préservées et réappliquées après restauration.'); } catch (e) { console.error('[restore] Échec de la réapplication des tables locales préservées :', e.message); } diff --git a/backend/src/routes/general.js b/backend/src/routes/general.js index d019496..84de970 100644 --- a/backend/src/routes/general.js +++ b/backend/src/routes/general.js @@ -27,9 +27,11 @@ function ensureRow() { // colonne applique automatiquement son DEFAULT (5 Go / 500 documents, // cf. migration dans db/index.js) quand elle est omise d'un INSERT. } - // parametres_environnement : normalement déjà créée avec sa ligne par défaut - // par la migration dans db/index.js (INSERT OR IGNORE) — filet de sécurité ici. + // parametres_environnement / parametres_theme : normalement déjà créées avec + // leur ligne par défaut par la migration dans db/index.js (INSERT OR IGNORE) + // — filet de sécurité ici. db.prepare('INSERT OR IGNORE INTO parametres_environnement (id) VALUES (1)').run(); + db.prepare('INSERT OR IGNORE INTO parametres_theme (id) VALUES (1)').run(); } // 1 Go = 1024^3 octets (convention déjà utilisée par fmtOctets côté frontend). @@ -40,10 +42,12 @@ router.get('/', (_req, res, next) => { ensureRow(); const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length, documents_quota_bytes, documents_quota_count FROM smtp_config WHERE id = 1').get(); const envRow = db.prepare('SELECT environnement FROM parametres_environnement WHERE id = 1').get(); + const themeRow = db.prepare('SELECT color_theme FROM parametres_theme WHERE id = 1').get(); res.json({ appName: row.app_name || 'Crowdlending Tracker', appUrl: row.app_url || '', environnement: envRow?.environnement || 'developpement', + colorTheme: themeRow?.color_theme || 'classique', mcpUrl: row.mcp_url || '', allowRegistration: row.allow_registration !== 0, minPasswordLength: row.min_password_length || 8, @@ -57,6 +61,7 @@ const PatchSchema = z.object({ appName: z.string().min(1).max(100).optional(), appUrl: z.string().max(500).optional(), environnement: z.enum(['production', 'qualification', 'developpement']).optional(), + colorTheme: z.enum(['classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil', 'violet', 'frais', 'contraste', 'pastel', 'moderne']).optional(), mcpUrl: z.string().max(500).optional(), allowRegistration: z.boolean().optional(), minPasswordLength: z.number().int().min(6).max(64).optional(), @@ -93,6 +98,9 @@ router.patch('/', (req, res, next) => { if (body.environnement !== undefined) { db.prepare('UPDATE parametres_environnement SET environnement = ? WHERE id = 1').run(body.environnement); } + if (body.colorTheme !== undefined) { + db.prepare('UPDATE parametres_theme SET color_theme = ? WHERE id = 1').run(body.colorTheme); + } res.json({ ok: true }); } catch (e) { next(e); } diff --git a/backend/src/routes/preferences.js b/backend/src/routes/preferences.js index d03aa89..d7f9b3f 100644 --- a/backend/src/routes/preferences.js +++ b/backend/src/routes/preferences.js @@ -10,7 +10,18 @@ const ALLOWED_KEYS = new Set([ 'chart_cashback', 'pfo_assujetti', 'objectifs_actifs', - // Extensible : ajouter ici les futures prefs (theme, font_scale, langue, devise, display_mode…) + 'color_theme', + // Extensible : ajouter ici les futures prefs (font_scale, langue, devise, display_mode…) +]); + +// Thème de couleur (Admin > Général, cf. project_theme_interface.md) — même +// énumération que la colonne parametres_theme.color_theme côté admin. +// Contrairement aux autres clés de ALLOWED_KEYS, la valeur est validée ici +// (pas seulement la clé) car une valeur invalide casserait silencieusement +// le rendu CSS ([data-color-theme] côté frontend n'a pas de fallback). +const COLOR_THEME_VALUES = new Set([ + 'classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil', + 'violet', 'frais', 'contraste', 'pastel', 'moderne', ]); /** @@ -51,6 +62,10 @@ router.patch('/', (req, res) => { return res.status(400).json({ error: `Clés inconnues : ${unknown.join(', ')}` }); } + if ('color_theme' in payload && !COLOR_THEME_VALUES.has(payload.color_theme)) { + return res.status(400).json({ error: `Valeur invalide pour color_theme : ${payload.color_theme}` }); + } + const persist = db.transaction(() => { for (const [key, value] of Object.entries(payload)) { upsert.run(req.user.id, key, String(value)); @@ -61,4 +76,21 @@ router.patch('/', (req, res) => { res.json({ ok: true }); }); +/** + * DELETE /api/preferences/:key + * Supprime la préférence de l'utilisateur connecté pour une clé donnée — + * utile pour revenir à un comportement "par défaut" (ex. "Automatique" + * pour le thème de couleur, cf. Settings > Apparence) plutôt que de + * stocker une valeur sentinelle : une clé absente n'est simplement plus + * retournée par GET (le frontend retombe alors sur sa valeur par défaut). + */ +router.delete('/:key', (req, res) => { + const { key } = req.params; + if (!ALLOWED_KEYS.has(key)) { + return res.status(400).json({ error: `Clé inconnue : ${key}` }); + } + db.prepare('DELETE FROM user_preferences WHERE user_id = ? AND key = ?').run(req.user.id, key); + res.json({ ok: true }); +}); + export default router; diff --git a/backend/src/server.js b/backend/src/server.js index 041b0f5..e9aea79 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -96,18 +96,23 @@ app.get('/api/health', (_, res) => res.json({ ok: true, ts: new Date().toISOStri // Informations publiques de la plateforme (utilisées sur la page de login) app.get('/api/app-info', (_, res) => { try { - const cfg = getSmtpConfig(); - const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get(); - const row = db.prepare('SELECT allow_registration, min_password_length, mcp_url FROM smtp_config WHERE id = 1').get(); + const cfg = getSmtpConfig(); + const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get(); + const row = db.prepare('SELECT allow_registration, min_password_length, mcp_url FROM smtp_config WHERE id = 1').get(); + // Thème de couleur (Admin > Général) : appliqué à TOUS les utilisateurs, + // y compris sur la page de connexion (avant authentification) — d'où son + // exposition ici, comme allowRegistration/mcpUrl déjà publics. + const theme = db.prepare('SELECT color_theme FROM parametres_theme WHERE id = 1').get(); res.json({ appName: cfg.appName || 'Crowdlending Tracker', iconUrl: icon ? `/api/icons-files/${icon.filename}` : null, allowRegistration: row ? row.allow_registration !== 0 : true, minPasswordLength: row ? (row.min_password_length || 8) : 8, mcpUrl: row ? (row.mcp_url || '') : '', + colorTheme: theme?.color_theme || 'classique', }); } catch { - res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8, mcpUrl: '' }); + res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8, mcpUrl: '', colorTheme: 'classique' }); } }); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 69263a6..48ea7f2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -56,6 +56,11 @@ function FraisOnly({ children }) { export default function App() { const [allowRegistration, setAllowRegistration] = useState(true); + // Note (06/09/26) : le thème de couleur (Admin > Général, ou choix + // personnel de l'utilisateur) n'est plus géré ici — voir UiContext.jsx + // (colorTheme / adminColorTheme), qui applique l'attribut data-color-theme + // sur et fonctionne aussi avant authentification (UiProvider + // enveloppe App dans main.jsx, y compris pour la page de connexion). useEffect(() => { fetch('/api/app-info').then(r => r.json()).then(d => { if (d.allowRegistration === false) setAllowRegistration(false); diff --git a/frontend/src/components/FraisCumulChart.jsx b/frontend/src/components/FraisCumulChart.jsx index c78069b..6518514 100644 --- a/frontend/src/components/FraisCumulChart.jsx +++ b/frontend/src/components/FraisCumulChart.jsx @@ -10,10 +10,13 @@ import { useMemo, useState, useRef } from 'react'; * montant par frais). */ -const COLOR = '#e8547a'; -const BG = '#070c15'; -const GRID = 'rgba(255,255,255,0.055)'; -const LABEL = '#4a5568'; +/* Couleurs alignées sur le thème actif (Admin > Général, cf. + * project_theme_interface.md) plutôt que fixées en dur — même mécanisme + * que InvChart.jsx / InteretsDonutChart.jsx. */ +const COLOR = 'var(--primary)'; +const BG = 'var(--surface)'; +const GRID = 'var(--border)'; +const LABEL = 'var(--text-muted)'; const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT']; const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.']; @@ -247,7 +250,7 @@ export default function FraisCumulChart({ rows }) { {/* Ligne zéro */} {yZero && yZero > PAD.top && yZero < PAD.top + plotH && ( + stroke={GRID} strokeWidth="1" /> )} {/* Remplissage dégradé */} @@ -273,7 +276,7 @@ export default function FraisCumulChart({ rows }) { {hover && ( + stroke={GRID} strokeWidth="1" strokeDasharray="3 4" /> diff --git a/frontend/src/components/InteretsChart.jsx b/frontend/src/components/InteretsChart.jsx index 799b38d..537d649 100644 --- a/frontend/src/components/InteretsChart.jsx +++ b/frontend/src/components/InteretsChart.jsx @@ -1,10 +1,13 @@ import { useMemo, useState, useRef } from 'react'; /* ── Constantes ─────────────────────────────────────────────── */ -const COLOR = '#4fa8e8'; -const BG = '#070c15'; -const GRID = 'rgba(255,255,255,0.055)'; -const LABEL = '#4a5568'; +/* Couleurs alignées sur le thème actif (Admin > Général, cf. + * project_theme_interface.md) plutôt que fixées en dur — même mécanisme + * que InvChart.jsx / InteretsDonutChart.jsx. */ +const COLOR = 'var(--primary)'; +const BG = 'var(--surface)'; +const GRID = 'var(--border)'; +const LABEL = 'var(--text-muted)'; const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT']; const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.']; @@ -237,7 +240,7 @@ export default function InteretsChart({ rows, netMode }) { {/* Ligne zéro */} {yZero && yZero > PAD.top && yZero < PAD.top + plotH && ( + stroke={GRID} strokeWidth="1" /> )} {/* Remplissage dégradé */} @@ -263,7 +266,7 @@ export default function InteretsChart({ rows, netMode }) { {hover && ( + stroke={GRID} strokeWidth="1" strokeDasharray="3 4" /> diff --git a/frontend/src/components/InteretsMensuelsChart.jsx b/frontend/src/components/InteretsMensuelsChart.jsx index 87227db..e5a51a8 100644 --- a/frontend/src/components/InteretsMensuelsChart.jsx +++ b/frontend/src/components/InteretsMensuelsChart.jsx @@ -1,11 +1,13 @@ import { useState, useRef, useEffect } from 'react'; import { api } from '../api.js'; import { useInteretsChart } from '../context/InteretsChartContext.jsx'; +import { useTheme } from '../context/ThemeContext.jsx'; const ICONS_BASE = '/api/icons-files/'; const GRID = 'rgba(255,255,255,0.055)'; -const LABEL = '#4a5568'; +const LABEL_LIGHT = '#4a5568'; +const LABEL_DARK = 'rgba(255,255,255,.65)'; const MOIS = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc']; function fmtShort(v) { @@ -40,6 +42,8 @@ export default function InteretsMensuelsChart() { netMode, chartInterets, chartCapital, chartCashback, } = useInteretsChart(); + const { resolved } = useTheme(); + const LABEL = resolved === 'dark' ? LABEL_DARK : LABEL_LIGHT; // État local uniquement const [hovered, setHovered] = useState(null); diff --git a/frontend/src/components/InvChart.jsx b/frontend/src/components/InvChart.jsx index d2349f2..4bc32b5 100644 --- a/frontend/src/components/InvChart.jsx +++ b/frontend/src/components/InvChart.jsx @@ -1,10 +1,14 @@ import { useMemo, useState, useRef } from 'react'; /* ── Constantes ─────────────────────────────────────────────── */ -const BLUE = '#4fa8e8'; -const BG = '#070c15'; -const GRID = 'rgba(255,255,255,0.055)'; -const LABEL = '#4a5568'; +/* Couleurs alignées sur le thème actif (Admin > Général, cf. + * project_theme_interface.md) plutôt que fixées en dur — même mécanisme + * que InteretsDonutChart.jsx (attributs de présentation SVG fill/stroke + * acceptant var(--...), supporté nativement par les navigateurs modernes). */ +const BLUE = 'var(--primary)'; +const BG = 'var(--surface)'; +const GRID = 'var(--border)'; +const LABEL = 'var(--text-muted)'; const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT']; const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.']; @@ -292,7 +296,7 @@ export default function InvChart({ rows, remboursements, reinvestissements, plat {/* Ligne zéro */} {yZero && yZero > PAD.top && yZero < PAD.top + plotH && ( + stroke={GRID} strokeWidth="1" /> )} {/* Aire dégradée + courbe */} @@ -316,7 +320,7 @@ export default function InvChart({ rows, remboursements, reinvestissements, plat {hover && ( + stroke={GRID} strokeWidth="1" strokeDasharray="3 4" /> diff --git a/frontend/src/components/Logo.jsx b/frontend/src/components/Logo.jsx index 31f4fbe..2cd1e5b 100644 --- a/frontend/src/components/Logo.jsx +++ b/frontend/src/components/Logo.jsx @@ -1,3 +1,12 @@ +/* Logo Crowdlending — carré aligné sur le thème d'interface actif + * (Admin > Général, cf. project_theme_interface.md) : le fond suit + * var(--primary), les 3 barres restent blanches à opacités variées + * pour la profondeur, et la flèche (accent "croissance") garde un + * vert dédié via var(--sidebar-accent) — stable quel que soit le + * thème de couleur choisi (non surchargé par les 9 thèmes) — plutôt + * que fixé en dur. Même mécanisme que InvChart.jsx / + * InteretsDonutChart.jsx (attributs de présentation SVG fill + * acceptant var(--...), supporté nativement). */ export default function Logo({ size = 32 }) { return ( ); } diff --git a/frontend/src/components/PageIcon.jsx b/frontend/src/components/PageIcon.jsx index 7d24e95..34aaf62 100644 --- a/frontend/src/components/PageIcon.jsx +++ b/frontend/src/components/PageIcon.jsx @@ -28,6 +28,7 @@ export default function PageIcon({ name, size = 40, style }) { return ( diff --git a/frontend/src/components/SoldeChart.jsx b/frontend/src/components/SoldeChart.jsx index c6a17e8..8828f3b 100644 --- a/frontend/src/components/SoldeChart.jsx +++ b/frontend/src/components/SoldeChart.jsx @@ -1,10 +1,13 @@ import { useMemo, useState, useRef, useEffect } from 'react'; /* ── Constantes ─────────────────────────────────────────────── */ -const GOLD = '#4fa8e8'; // bleu ciel — accord avec le thème navy du site -const BG = '#070c15'; -const GRID = 'rgba(255,255,255,0.055)'; -const LABEL = '#4a5568'; +/* Couleurs alignées sur le thème actif (Admin > Général, cf. + * project_theme_interface.md) plutôt que fixées en dur — même mécanisme + * que InvChart.jsx / InteretsDonutChart.jsx. */ +const GOLD = 'var(--primary)'; +const BG = 'var(--surface)'; +const GRID = 'var(--border)'; +const LABEL = 'var(--text-muted)'; const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT']; @@ -254,7 +257,7 @@ export default function SoldeChart({ rows, range: rangeProp, onRangeChange }) { {/* Ligne zéro */} {yZero && yZero > PAD.top && yZero < PAD.top + plotH && ( + stroke={GRID} strokeWidth="1" /> )} {/* Remplissage dégradé */} @@ -280,7 +283,7 @@ export default function SoldeChart({ rows, range: rangeProp, onRangeChange }) { {hover && ( + stroke={GRID} strokeWidth="1" strokeDasharray="3 4" /> diff --git a/frontend/src/components/ThemeSelect.jsx b/frontend/src/components/ThemeSelect.jsx new file mode 100644 index 0000000..b765f36 --- /dev/null +++ b/frontend/src/components/ThemeSelect.jsx @@ -0,0 +1,80 @@ +/** + * ThemeSelect — sélecteur déroulant pour un choix unique avec pastille de + * couleur (ex : thème de couleur de l'interface, Admin > Général). + * Même schéma d'interaction que CategorySelect (bouton déclencheur + + * dropdown, fermeture au clic extérieur / scroll / resize), en position + * absolue (pas de portail fixed : utilisé dans une page classique, pas + * dans une modale au overflow contraint). + * + * Props : + * value : string — value sélectionnée + * onChange : (value: string) => void + * options : { value, label, swatch }[] — liste des choix + */ +import { useEffect, useRef, useState } from 'react'; + +export default function ThemeSelect({ value, onChange, options = [] }) { + const [open, setOpen] = useState(false); + const wrapRef = useRef(null); + + useEffect(() => { + if (!open) return; + const close = (e) => { + if (wrapRef.current?.contains(e.target)) return; + setOpen(false); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [open]); + + const current = options.find(o => o.value === value) || options[0]; + + return ( +
+ + + {open && ( +
+ {options.map(({ value: v, label, swatch }) => { + const active = v === value; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/UserMenu.jsx b/frontend/src/components/UserMenu.jsx index 7efc32b..01b5537 100644 --- a/frontend/src/components/UserMenu.jsx +++ b/frontend/src/components/UserMenu.jsx @@ -56,7 +56,7 @@ function UserAvatar({ user, size = 36 }) { return (
Général, cf. adminColorTheme). Stocké + * en base (table user_preferences, clé color_theme) + * pour suivre l'utilisateur d'un appareil à l'autre — + * voir project_theme_interface.md. */ const UiCtx = createContext(null); @@ -19,6 +28,8 @@ const KEY_CHART_CAPITAL = 'cl_chart_capital'; const KEY_CHART_CASHBACK = 'cl_chart_cashback'; const KEY_PFO_ASSUJETTI = 'cl_pfo_assujetti'; const KEY_OBJECTIFS_ACTIFS = 'cl_objectifs_actifs'; +const KEY_COLOR_THEME = 'cl_color_theme'; +const KEY_ADMIN_COLOR_THEME = 'cl_admin_color_theme'; const FONT_SIZES = ['large', 'medium', 'compact']; const LANGUES = ['fr', 'en']; @@ -79,6 +90,37 @@ export function UiProvider({ children }) { return saved === null ? false : saved === '1'; }); + // Thème de couleur personnel (06/09/26) : null = "Automatique" (suit + // adminColorTheme). Une valeur explicite ici prime toujours sur le thème + // par défaut de l'administrateur. + const [colorTheme, setColorThemeState] = useState(() => + localStorage.getItem(KEY_COLOR_THEME) || null + ); + + // Thème par défaut défini par l'administrateur (Admin > Général), utilisé + // en repli quand colorTheme est null. Mis en cache en localStorage pour + // éviter un flash au chargement le temps que /api/app-info réponde. + const [adminColorTheme, setAdminColorThemeState] = useState(() => + localStorage.getItem(KEY_ADMIN_COLOR_THEME) || 'classique' + ); + + // ── Thème par défaut (admin) : chargé une fois au montage ─────── + // Endpoint public (pas d'auth requise) : reste valable sur la page de + // connexion, comme dans App.jsx. + useEffect(() => { + fetch('/api/app-info').then(r => r.json()).then(d => { + const t = d.colorTheme || 'classique'; + localStorage.setItem(KEY_ADMIN_COLOR_THEME, t); + setAdminColorThemeState(t); + }).catch(() => {}); + }, []); + + // ── Applique le thème de couleur effectif sur ──────────── + // Un choix personnel (colorTheme) prime toujours sur le défaut admin. + useEffect(() => { + document.documentElement.setAttribute('data-color-theme', colorTheme || adminColorTheme); + }, [colorTheme, adminColorTheme]); + // ── Sync DB → localStorage au montage ────────────────────────── // La DB fait foi : ses valeurs écrasent le localStorage si présentes. useEffect(() => { @@ -105,6 +147,10 @@ export function UiProvider({ children }) { localStorage.setItem(KEY_OBJECTIFS_ACTIFS, val ? '1' : '0'); setObjectifsActifsState(val); } + if (prefs.color_theme) { + localStorage.setItem(KEY_COLOR_THEME, prefs.color_theme); + setColorThemeState(prefs.color_theme); + } }).catch(() => { // Silencieux : token absent au premier rendu, on garde le localStorage }); @@ -163,6 +209,37 @@ export function UiProvider({ children }) { persistPref('chart_cashback', hex); }, [persistPref]); + // Adapte automatiquement la palette de couleurs de graphiques (section + // ci-dessus) sur celle associée au thème donné (même logique que le + // reverse-coupling admin de GeneralSection.jsx) — appelé uniquement lors + // d'un changement explicite de thème, jamais lors de la resynchronisation + // silencieuse au montage (sinon un choix de couleurs personnalisé serait + // écrasé à chaque rechargement de page). + const applyPaletteForTheme = useCallback((themeValue) => { + const paletteName = THEME_TO_PALETTE_NAME[themeValue]; + if (!paletteName) return; + const palette = getSuggestedPalettes().find(p => p.name === paletteName); + if (!palette) return; + setChartInterets(palette.interets); + setChartCapital(palette.capital); + setChartCashback(palette.cashback); + }, [setChartInterets, setChartCapital, setChartCashback]); + + // value = un des thèmes de colorThemes.js pour un choix personnel explicite, + // ou null/undefined pour revenir à "Automatique" (suit le thème par défaut + // de l'administrateur — cf. adminColorTheme). + const setColorTheme = useCallback((value) => { + setColorThemeState(value || null); + if (value) { + localStorage.setItem(KEY_COLOR_THEME, value); + persistPref('color_theme', value); + } else { + localStorage.removeItem(KEY_COLOR_THEME); + api.del('/preferences/color_theme').catch(() => {}); + } + applyPaletteForTheme(value || adminColorTheme); + }, [persistPref, applyPaletteForTheme, adminColorTheme]); + const setPfoAssujetti = useCallback((val) => { setPfoAssujettiState(val); localStorage.setItem(KEY_PFO_ASSUJETTI, val ? '1' : '0'); @@ -176,7 +253,7 @@ export function UiProvider({ children }) { }, [persistPref]); return ( - + {children} ); diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 6046438..445e991 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -48,12 +48,12 @@ function YearSelectorKpi() { onClick={() => setOpen(v => !v)} style={{ height: '100%', boxSizing: 'border-box', - background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)', + background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)', borderRadius: 10, padding: '16px 20px', boxShadow: open - ? '0 6px 28px rgba(109,40,217,0.45)' - : '0 4px 20px rgba(109,40,217,0.30)', + ? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)' + : '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)', cursor: 'pointer', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', userSelect: 'none', @@ -101,8 +101,8 @@ function YearSelectorKpi() { style={{ padding: '10px 16px', cursor: 'pointer', - background: isActive ? 'rgba(109,40,217,0.08)' : 'transparent', - color: isActive ? '#7c3aed' : 'var(--text)', + background: isActive ? 'color-mix(in srgb, var(--primary) 8%, transparent)' : 'transparent', + color: isActive ? 'var(--primary)' : 'var(--text)', fontWeight: isActive ? 700 : 400, fontSize: 'var(--fs-sm)', borderBottom: i < options.length - 1 ? '1px solid var(--border)' : 'none', @@ -115,7 +115,7 @@ function YearSelectorKpi() { {opt.label} {isActive && ( + stroke="var(--primary)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> )} diff --git a/frontend/src/pages/FamilleEntreprises.jsx b/frontend/src/pages/FamilleEntreprises.jsx index 261f97c..d4bf098 100644 --- a/frontend/src/pages/FamilleEntreprises.jsx +++ b/frontend/src/pages/FamilleEntreprises.jsx @@ -9,8 +9,8 @@ import ConfirmModal from '../components/ConfirmModal.jsx'; function MemberAvatar({ membre, size = 40 }) { const initials = memberInitials(membre); const bg = membre.type === 'entreprise' - ? 'linear-gradient(135deg, #4f46e5 0%, #3730a3 100%)' - : 'linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%)'; + ? 'linear-gradient(135deg, var(--avatar-entreprise-start) 0%, var(--avatar-entreprise-end) 100%)' + : 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)'; return (
setOpen(v => !v)} style={{ - background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)', + background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)', borderRadius: 10, padding: '12px 16px', - boxShadow: open ? '0 6px 28px rgba(109,40,217,0.45)' : '0 4px 20px rgba(109,40,217,0.30)', + boxShadow: open + ? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)' + : '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)', cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s', }} > @@ -89,7 +91,7 @@ function PlatSelector({ platOptions, selectedPlatName, onSelect }) { {selected?.icon_filename && ( )} @@ -156,9 +158,11 @@ function DetenteurSelector({ detenteurOptions, selectedId, onSelect }) {
setOpen(v => !v)} style={{ - background: 'linear-gradient(135deg, #0d9488 0%, #0891b2 100%)', + background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)', borderRadius: 10, padding: '12px 16px', - boxShadow: open ? '0 6px 22px rgba(13,148,136,0.45)' : '0 4px 16px rgba(13,148,136,0.3)', + boxShadow: open + ? '0 6px 22px color-mix(in srgb, var(--primary) 45%, transparent)' + : '0 4px 16px color-mix(in srgb, var(--primary) 30%, transparent)', cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s', }} > @@ -230,9 +234,11 @@ function AnneeSelector({ availableYears, selectedYear, onSelect }) {
setOpen(v => !v)} style={{ - background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)', + background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)', borderRadius: 10, padding: '12px 16px', - boxShadow: open ? '0 6px 28px rgba(109,40,217,0.45)' : '0 4px 20px rgba(109,40,217,0.30)', + boxShadow: open + ? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)' + : '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)', cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s', }} > diff --git a/frontend/src/pages/TaxReport.jsx b/frontend/src/pages/TaxReport.jsx index fdf34af..c339b35 100644 --- a/frontend/src/pages/TaxReport.jsx +++ b/frontend/src/pages/TaxReport.jsx @@ -32,12 +32,12 @@ function YearSelector({ annee, setAnnee, availableYears }) { onClick={() => setOpen(v => !v)} style={{ height: '100%', boxSizing: 'border-box', - background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)', + background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)', borderRadius: 10, padding: '12px 16px', boxShadow: open - ? '0 6px 28px rgba(109,40,217,0.45)' - : '0 4px 20px rgba(109,40,217,0.30)', + ? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)' + : '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)', cursor: 'pointer', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', userSelect: 'none', @@ -76,8 +76,8 @@ function YearSelector({ annee, setAnnee, availableYears }) { onClick={() => { setAnnee(String(yr)); setOpen(false); }} style={{ padding: '10px 16px', cursor: 'pointer', - background: isActive ? 'rgba(109,40,217,0.08)' : 'transparent', - color: isActive ? '#7c3aed' : 'var(--text)', + background: isActive ? 'color-mix(in srgb, var(--primary) 8%, transparent)' : 'transparent', + color: isActive ? 'var(--primary)' : 'var(--text)', fontWeight: isActive ? 700 : 400, fontSize: 'var(--fs-sm)', borderBottom: i < availableYears.length - 1 ? '1px solid var(--border)' : 'none', @@ -90,7 +90,7 @@ function YearSelector({ annee, setAnnee, availableYears }) { {yr} {isActive && ( + stroke="var(--primary)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> )} diff --git a/frontend/src/pages/admin/GeneralSection.jsx b/frontend/src/pages/admin/GeneralSection.jsx index 2a503df..d1f59e3 100644 --- a/frontend/src/pages/admin/GeneralSection.jsx +++ b/frontend/src/pages/admin/GeneralSection.jsx @@ -6,6 +6,10 @@ import { useState, useEffect } from 'react'; import { api } from '../../api.js'; import { withDevOverrides } from '../../utils/devOverrides.js'; +import { getSuggestedPalettes } from '../../utils/chartPalettes.js'; +import { COLOR_THEMES, THEME_TO_PALETTE_NAME } from '../../utils/colorThemes.js'; +import { useUi } from '../../context/UiContext.jsx'; +import ThemeSelect from '../../components/ThemeSelect.jsx'; function SettingRow({ label, description, children }) { return ( @@ -36,6 +40,7 @@ const DEFAULT = { appName: 'Crowdlending Tracker', appUrl: '', environnement: 'developpement', + colorTheme: 'classique', mcpUrl: '', allowRegistration: true, minPasswordLength: 8, @@ -50,6 +55,7 @@ const ENVIRONNEMENTS = [ ]; export default function GeneralSection() { + const { setChartInterets, setChartCapital, setChartCashback } = useUi(); const [form, setForm] = useState(DEFAULT); const [saved, setSaved] = useState(null); // 'ok' | 'err' | null const [busy, setBusy] = useState(false); @@ -66,6 +72,7 @@ export default function GeneralSection() { appName: dd.appName || 'Crowdlending Tracker', appUrl: dd.appUrl || '', environnement: dd.environnement || 'developpement', + colorTheme: dd.colorTheme || 'classique', mcpUrl: dd.mcpUrl || '', allowRegistration: dd.allowRegistration !== false, minPasswordLength: dd.minPasswordLength || 8, @@ -86,12 +93,29 @@ export default function GeneralSection() { appName: form.appName.trim(), appUrl: form.appUrl.trim(), environnement: form.environnement, + colorTheme: form.colorTheme, mcpUrl: form.mcpUrl.trim(), allowRegistration: form.allowRegistration, minPasswordLength: form.minPasswordLength, documentsQuotaGo: form.documentsQuotaGo, documentsQuotaCount: form.documentsQuotaCount, }); + // Applique immédiatement la nouvelle teinte à cette session (sans + // attendre un rechargement) — mêmes attribut/valeurs que App.jsx au + // boot ('classique' = no-op, aucun bloc CSS [data-color-theme="classique"]). + document.documentElement.setAttribute('data-color-theme', form.colorTheme); + // Adapte automatiquement, sur ce compte, la palette de couleurs de + // graphiques (Settings > Apparence) sur celle du thème qui vient + // d'être choisi (même nom) — cf. project_theme_interface.md. + const paletteName = THEME_TO_PALETTE_NAME[form.colorTheme]; + if (paletteName) { + const palette = getSuggestedPalettes().find(p => p.name === paletteName); + if (palette) { + setChartInterets(palette.interets); + setChartCapital(palette.capital); + setChartCashback(palette.cashback); + } + } setSaved('ok'); setTimeout(() => setSaved(null), 3000); } catch { @@ -165,6 +189,28 @@ export default function GeneralSection() {
+ {/* Apparence */} +
+

Apparence

+

+ Thème de couleur par défaut de l'interface, appliqué à tout utilisateur + n'ayant pas choisi son propre thème (Réglages > Apparence). +

+ + +
+ set('colorTheme', v)} + options={COLOR_THEMES} + /> +
+
+
+ {/* Accès */}

Accès

diff --git a/frontend/src/pages/settings/AppearanceSection.jsx b/frontend/src/pages/settings/AppearanceSection.jsx index ff6561d..5566da0 100644 --- a/frontend/src/pages/settings/AppearanceSection.jsx +++ b/frontend/src/pages/settings/AppearanceSection.jsx @@ -1,7 +1,10 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useTheme } from '../../context/ThemeContext.jsx'; import { useUi } from '../../context/UiContext.jsx'; import { api } from '../../api.js'; +import { getSuggestedPalettes } from '../../utils/chartPalettes.js'; +import { COLOR_THEMES, colorThemeSwatch } from '../../utils/colorThemes.js'; +import ThemeSelect from '../../components/ThemeSelect.jsx'; /* ── Apparence — données ─────────────────────────────────────── */ @@ -109,7 +112,8 @@ function isLightColor(hex) { return (r * 299 + g * 587 + b * 114) / 1000 > 155; } -/* Retrouve la famille et la teinte d'un hex donné */ +/* Retrouve la famille et la teinte d'un hex donné (correspondance EXACTE + * à une nuance MD_PALETTE) */ function findInPalette(hex) { const h = hex.toLowerCase(); for (const [family, { shades }] of Object.entries(MD_PALETTE)) { @@ -120,6 +124,51 @@ function findInPalette(hex) { return null; } +/* Teinte (0-360) d'un hex, sans dépendance externe */ +function hexHue(hex) { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + if (max === min) return 0; + const d = max - min; + let h; + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)); + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + return h * 60; +} + +/* Famille MD_PALETTE la plus proche en teinte pour un hex qui ne + * correspond à AUCUNE nuance exacte (ex. couleur de thème dynamique pour + * "Classique", ou couleurs des 9 autres palettes suggérées, calculées par + * rotation HSL et non alignées sur les nuances MD_PALETTE) — évite + * d'afficher une famille sans rapport avec la couleur réelle (bug + * rencontré le 05/09/26 : famille "Vert" restée affichée, avec son point + * indicateur vert, après avoir choisi la palette "Coucher de soleil" + * (orange), car l'ancien code laissait `selectedFamily` inchangé — donc + * périmée — dès que `findInPalette` ne trouvait aucune correspondance + * exacte). Ne présuppose jamais une correspondance exacte de nuance : le + * point indicateur affiche toujours la vraie couleur (`value`), jamais + * `familyData.base`. */ +function findNearestFamily(hex) { + const targetHue = hexHue(hex); + let best = 'Bleu', bestDist = Infinity; + for (const [family, { base }] of Object.entries(MD_PALETTE)) { + // "Gris" est achromatique (R=G=B) : sa teinte n'a mathématiquement pas + // de sens (0 par convention), ce qui le fait à tort "gagner" pour les + // couleurs proches du rouge (teinte elle aussi proche de 0). L'exclure + // du calcul de plus proche teinte. + if (family === 'Gris') continue; + const dist = Math.min( + Math.abs(hexHue(base) - targetHue), + 360 - Math.abs(hexHue(base) - targetHue) + ); + if (dist < bestDist) { bestDist = dist; best = family; } + } + return best; +} + /* Mélange une couleur hex avec du blanc (factor 0=original, 1=blanc) */ function lightenColor(hex, factor = 0.72) { const r = parseInt(hex.slice(1,3),16); @@ -131,21 +180,7 @@ function lightenColor(hex, factor = 0.72) { return '#' + [lr,lg,lb].map(v => v.toString(16).padStart(2,'0')).join(''); } -/* ── Palettes suggérées ──────────────────────────────────────── */ -const SUGGESTED_PALETTES = [ - { name: 'Classique', interets: '#2196f3', capital: '#4caf50', cashback: '#ffc107' }, - { name: 'Indigo & Teal', interets: '#3949ab', capital: '#009688', cashback: '#fb8c00' }, - { name: 'Nuit', interets: '#5c6bc0', capital: '#4dd0e1', cashback: '#ffca28' }, - { name: 'Nature', interets: '#43a047', capital: '#039be5', cashback: '#ff9800' }, - { name: 'Coucher de soleil', interets: '#ff5722', capital: '#1976d2', cashback: '#ffa000' }, - { name: 'Violet', interets: '#7e57c2', capital: '#26a69a', cashback: '#ffb300' }, - { name: 'Frais', interets: '#00acc1', capital: '#7cb342', cashback: '#ec407a' }, - { name: 'Contraste', interets: '#e53935', capital: '#1e88e5', cashback: '#43a047' }, - { name: 'Pastel', interets: '#29b6f6', capital: '#66bb6a', cashback: '#ffb74d' }, - { name: 'Moderne', interets: '#9c27b0', capital: '#00bcd4', cashback: '#ff9800' }, -]; - -function PaletteSelector({ interets, capital, cashback, onSelect }) { +function PaletteSelector({ interets, capital, cashback, onSelect, palettes }) { const isActive = (p) => p.interets === interets && p.capital === capital && p.cashback === cashback; @@ -155,7 +190,7 @@ function PaletteSelector({ interets, capital, cashback, onSelect }) { Palettes suggérées

- {SUGGESTED_PALETTES.map((p) => ( + {palettes.map((p) => (