Gestion des Thèmes

This commit is contained in:
ocroguennec committed 2026-09-06 18:59:09 +02:00
1 parent bab7a79b63
commit be3c93897b
24 files changed
+1041 -141

No files matched your search

+6 -6
View File
@@ -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() {
<span>{opt.label}</span>
{isActive && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
stroke="var(--primary)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
+2 -2
View File
@@ -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 (
<div style={{
width: size, height: size, borderRadius: '50%',
+13 -7
View File
@@ -69,9 +69,11 @@ function PlatSelector({ platOptions, selectedPlatName, onSelect }) {
<div
onClick={() => 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 && (
<img src={`${LOGOS_BASE}${selected.icon_filename}`}
width={28} height={28}
style={{ objectFit: 'contain', borderRadius: 4, background: 'rgba(255,255,255,0.12)', padding: 2, flexShrink: 0 }}
style={{ objectFit: 'contain', borderRadius: 4, background: '#fff', padding: 2, flexShrink: 0 }}
alt="" />
)}
<span style={{ color: '#fff', fontSize: '1.1rem', fontWeight: 700, lineHeight: 1.2 }}>
@@ -156,9 +158,11 @@ function DetenteurSelector({ detenteurOptions, selectedId, onSelect }) {
<div
onClick={() => 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 }) {
<div
onClick={() => 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',
}}
>
+6 -6
View File
@@ -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 }) {
<span>{yr}</span>
{isActive && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
stroke="var(--primary)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
@@ -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() {
</SettingRow>
</div>
{/* Apparence */}
<div className="card" style={{ marginBottom: 24 }}>
<h3 style={{ margin: '0 0 4px', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Apparence</h3>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>
Thème de couleur par défaut de l'interface, appliqué à tout utilisateur
n'ayant pas choisi son propre thème (Réglages &gt; Apparence).
</p>
<SettingRow
label="Thème de couleur"
description="Teinte des menus et boutons appliquée par défaut à tout compte n'ayant pas défini son propre thème dans Réglages > Apparence (chacun peut y choisir une couleur différente, stockée sur son compte). Utile pour repérer visuellement cet environnement au premier coup d'œil, par exemple en gardant Production dans le bleu par défaut et en donnant une autre couleur à Qualification/Développement. Conservé tel quel lors d'une restauration de base copiée depuis un autre environnement (comme Environnement ci-dessus)."
>
<div style={{ width: 260 }}>
<ThemeSelect
value={form.colorTheme}
onChange={(v) => set('colorTheme', v)}
options={COLOR_THEMES}
/>
</div>
</SettingRow>
</div>
{/* Accès */}
<div className="card" style={{ marginBottom: 24 }}>
<h3 style={{ margin: '0 0 4px', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Accès</h3>
@@ -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
</p>
<div className="palette-grid">
{SUGGESTED_PALETTES.map((p) => (
{palettes.map((p) => (
<button
key={p.name}
type="button"
@@ -178,12 +213,15 @@ function PaletteSelector({ interets, capital, cashback, onSelect }) {
function ChartColorPicker({ value, onChange }) {
const found = findInPalette(value);
const [selectedFamily, setSelectedFamily] = useState(found?.family || 'Bleu');
const [selectedFamily, setSelectedFamily] = useState(found?.family || findNearestFamily(value));
// Sync la famille quand value change depuis l'extérieur (sélection palette)
// Sync la famille quand value change depuis l'extérieur (sélection
// palette) : toujours retomber sur la famille la plus proche en teinte
// à défaut de correspondance exacte, jamais laisser `selectedFamily`
// périmé (cf. commentaire de findNearestFamily ci-dessus).
useEffect(() => {
const f = findInPalette(value);
if (f) setSelectedFamily(f.family);
setSelectedFamily(f ? f.family : findNearestFamily(value));
}, [value]);
const familyData = MD_PALETTE[selectedFamily];
@@ -203,7 +241,7 @@ function ChartColorPicker({ value, onChange }) {
return (
<div className="chart-color-picker">
<div className="color-family-select-row">
<span className="color-family-indicator" style={{ backgroundColor: familyData?.base || value }} />
<span className="color-family-indicator" style={{ backgroundColor: value }} />
<select value={selectedFamily} onChange={handleFamilyChange}>
{Object.keys(MD_PALETTE).map(name => (
<option key={name} value={name}>{name}</option>
@@ -242,8 +280,15 @@ function ChartColorPicker({ value, onChange }) {
export default function AppearanceSection() {
const { mode, setMode } = useTheme();
const { fontScale, setFontScale, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback } = useUi();
const { fontScale, setFontScale, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, colorTheme, adminColorTheme, setColorTheme } = useUi();
const [libIcons, setLibIcons] = useState({});
const suggestedPalettes = useMemo(() => getSuggestedPalettes(), []);
// "Automatique" (value '') suit le thème par défaut de l'administrateur —
// sa pastille reflète donc la couleur réellement appliquée dans ce cas.
const colorThemeOptions = useMemo(() => [
{ value: '', label: 'Automatique (thème par défaut)', swatch: colorThemeSwatch(adminColorTheme) },
...COLOR_THEMES,
], [adminColorTheme]);
useEffect(() => {
api.get('/icons').then(rows => {
@@ -271,6 +316,25 @@ export default function AppearanceSection() {
</button>
))}
</div>
{/* Thème de couleur personnel (06/09/26) : indépendant du défaut admin
(Admin > Général), stocké en base sur ce compte (cf. UiContext.jsx) */}
<div style={{ marginTop: 20, paddingTop: 20, borderTop: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
<div>
<div style={{ fontWeight: 700, fontSize: 'var(--fs-sm)' }}>Couleur de l'application</div>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2, maxWidth: 420 }}>
Teinte des menus et boutons, propre à votre compte et conservée d'un appareil à l'autre.
« Automatique » suit le thème par défaut choisi par l'administrateur.
</div>
</div>
<div style={{ width: 260, flexShrink: 0 }}>
<ThemeSelect
value={colorTheme || ''}
onChange={(v) => setColorTheme(v || null)}
options={colorThemeOptions}
/>
</div>
</div>
</div>
<div className="card">
@@ -310,6 +374,7 @@ export default function AppearanceSection() {
interets={chartInterets}
capital={chartCapital}
cashback={chartCashback}
palettes={suggestedPalettes}
onSelect={(p) => { setChartInterets(p.interets); setChartCapital(p.capital); setChartCashback(p.cashback); }}
/>
<div className="chart-color-row">