344 lines
19 KiB
React
344 lines
19 KiB
React
import { useState, useEffect } from 'react';
|
|
import { useTheme } from '../../context/ThemeContext.jsx';
|
|
import { useUi } from '../../context/UiContext.jsx';
|
|
import { api } from '../../api.js';
|
|
|
|
|
|
/* ── Apparence — données ─────────────────────────────────────── */
|
|
const THEMES = [
|
|
{
|
|
mode: 'light', label: 'Clair', desc: 'Interface lumineuse',
|
|
preview: (
|
|
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
|
|
<rect width="56" height="36" rx="5" fill="#f0f4ff"/>
|
|
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
|
|
<rect x="16" y="2" width="38" height="8" rx="2" fill="#ffffff" opacity=".9"/>
|
|
<rect x="16" y="12" width="38" height="5" rx="2" fill="#c7d2e8"/>
|
|
<rect x="16" y="19" width="28" height="5" rx="2" fill="#c7d2e8"/>
|
|
<rect x="16" y="26" width="20" height="5" rx="2" fill="#c7d2e8"/>
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
mode: 'dark', label: 'Sombre', desc: 'Interface nocturne',
|
|
preview: (
|
|
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
|
|
<rect width="56" height="36" rx="5" fill="#060e1f"/>
|
|
<rect x="2" y="2" width="12" height="32" rx="3" fill="#0d1629"/>
|
|
<rect x="16" y="2" width="38" height="8" rx="2" fill="#111c35" opacity=".9"/>
|
|
<rect x="16" y="12" width="38" height="5" rx="2" fill="#1e3a6a"/>
|
|
<rect x="16" y="19" width="28" height="5" rx="2" fill="#1e3a6a"/>
|
|
<rect x="16" y="26" width="20" height="5" rx="2" fill="#1e3a6a"/>
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
mode: 'system', label: 'Système', desc: 'Suit les préférences OS',
|
|
preview: (
|
|
<svg viewBox="0 0 56 36" width="56" height="36" aria-hidden="true">
|
|
<defs>
|
|
<linearGradient id="split" x1="0" x2="1" y1="0" y2="0">
|
|
<stop offset="50%" stopColor="#f0f4ff"/>
|
|
<stop offset="50%" stopColor="#060e1f"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<rect width="56" height="36" rx="5" fill="url(#split)"/>
|
|
<rect x="2" y="2" width="12" height="32" rx="3" fill="#1e3a8a"/>
|
|
<rect x="16" y="2" width="18" height="8" rx="2" fill="#ffffff" opacity=".9"/>
|
|
<rect x="36" y="2" width="18" height="8" rx="2" fill="#111c35" opacity=".9"/>
|
|
<rect x="16" y="12" width="18" height="5" rx="2" fill="#c7d2e8"/>
|
|
<rect x="36" y="12" width="18" height="5" rx="2" fill="#1e3a6a"/>
|
|
</svg>
|
|
),
|
|
},
|
|
];
|
|
|
|
const FONTS = [
|
|
{ scale: 'compact', label: 'Normal', desc: 'Interface compacte', sizes: { body: 12, table: 11 } },
|
|
{ scale: 'medium', label: 'Moyen', desc: 'Taille intermédiaire', sizes: { body: 13, table: 12 } },
|
|
{ scale: 'large', label: 'Grand', desc: 'Meilleure lisibilité', sizes: { body: 14, table: 13 } },
|
|
];
|
|
|
|
|
|
/* ── Icônes graphiques (couleurs) ────────────────────────────── */
|
|
const ICONS_BASE = '/api/icons-files/';
|
|
|
|
function AppIcon({ filename, size = 22 }) {
|
|
if (!filename) return null;
|
|
return (
|
|
<img
|
|
src={`${ICONS_BASE}${filename}`}
|
|
alt=""
|
|
width={size}
|
|
height={size}
|
|
className="app-lib-icon"
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|
|
|
|
|
|
/* ── Palette Material Design 2 ───────────────────────────────── */
|
|
const MD_PALETTE = {
|
|
'Rouge': { base: '#f44336', shades: { '50':'#ffebee','100':'#ffcdd2','200':'#ef9a9a','300':'#e57373','400':'#ef5350','500':'#f44336','600':'#e53935','700':'#d32f2f','800':'#c62828','900':'#b71c1c','A100':'#ff8a80','A200':'#ff5252','A400':'#ff1744','A700':'#d50000' } },
|
|
'Rose': { base: '#e91e63', shades: { '50':'#fce4ec','100':'#f8bbd0','200':'#f48fb1','300':'#f06292','400':'#ec407a','500':'#e91e63','600':'#d81b60','700':'#c2185b','800':'#ad1457','900':'#880e4f','A100':'#ff80ab','A200':'#ff4081','A400':'#f50057','A700':'#c51162' } },
|
|
'Violet': { base: '#9c27b0', shades: { '50':'#f3e5f5','100':'#e1bee7','200':'#ce93d8','300':'#ba68c8','400':'#ab47bc','500':'#9c27b0','600':'#8e24aa','700':'#7b1fa2','800':'#6a1b9a','900':'#4a148c','A100':'#ea80fc','A200':'#e040fb','A400':'#d500f9','A700':'#aa00ff' } },
|
|
'Violet foncé': { base: '#673ab7', shades: { '50':'#ede7f6','100':'#d1c4e9','200':'#b39ddb','300':'#9575cd','400':'#7e57c2','500':'#673ab7','600':'#5e35b1','700':'#512da8','800':'#4527a0','900':'#311b92','A100':'#b388ff','A200':'#7c4dff','A400':'#651fff','A700':'#6200ea' } },
|
|
'Indigo': { base: '#3f51b5', shades: { '50':'#e8eaf6','100':'#c5cae9','200':'#9fa8da','300':'#7986cb','400':'#5c6bc0','500':'#3f51b5','600':'#3949ab','700':'#303f9f','800':'#283593','900':'#1a237e','A100':'#8c9eff','A200':'#536dfe','A400':'#3d5afe','A700':'#304ffe' } },
|
|
'Bleu': { base: '#2196f3', shades: { '50':'#e3f2fd','100':'#bbdefb','200':'#90caf9','300':'#64b5f6','400':'#42a5f5','500':'#2196f3','600':'#1e88e5','700':'#1976d2','800':'#1565c0','900':'#0d47a1','A100':'#82b1ff','A200':'#448aff','A400':'#2979ff','A700':'#2962ff' } },
|
|
'Bleu clair': { base: '#03a9f4', shades: { '50':'#e1f5fe','100':'#b3e5fc','200':'#81d4fa','300':'#4fc3f7','400':'#29b6f6','500':'#03a9f4','600':'#039be5','700':'#0288d1','800':'#0277bd','900':'#01579b','A100':'#80d8ff','A200':'#40c4ff','A400':'#00b0ff','A700':'#0091ea' } },
|
|
'Cyan': { base: '#00bcd4', shades: { '50':'#e0f7fa','100':'#b2ebf2','200':'#80deea','300':'#4dd0e1','400':'#26c6da','500':'#00bcd4','600':'#00acc1','700':'#0097a7','800':'#00838f','900':'#006064','A100':'#84ffff','A200':'#18ffff','A400':'#00e5ff','A700':'#00b8d4' } },
|
|
'Sarcelle': { base: '#009688', shades: { '50':'#e0f2f1','100':'#b2dfdb','200':'#80cbc4','300':'#4db6ac','400':'#26a69a','500':'#009688','600':'#00897b','700':'#00796b','800':'#00695c','900':'#004d40','A100':'#a7ffeb','A200':'#64ffda','A400':'#1de9b6','A700':'#00bfa5' } },
|
|
'Vert': { base: '#4caf50', shades: { '50':'#e8f5e9','100':'#c8e6c9','200':'#a5d6a7','300':'#81c784','400':'#66bb6a','500':'#4caf50','600':'#43a047','700':'#388e3c','800':'#2e7d32','900':'#1b5e20','A100':'#b9f6ca','A200':'#69f0ae','A400':'#00e676','A700':'#00c853' } },
|
|
'Vert clair': { base: '#8bc34a', shades: { '50':'#f1f8e9','100':'#dcedc8','200':'#c5e1a5','300':'#aed581','400':'#9ccc65','500':'#8bc34a','600':'#7cb342','700':'#689f38','800':'#558b2f','900':'#33691e','A100':'#ccff90','A200':'#b2ff59','A400':'#76ff03','A700':'#64dd17' } },
|
|
'Citron vert': { base: '#cddc39', shades: { '50':'#f9fbe7','100':'#f0f4c3','200':'#e6ee9c','300':'#dce775','400':'#d4e157','500':'#cddc39','600':'#c0ca33','700':'#afb42b','800':'#9e9d24','900':'#827717','A100':'#f4ff81','A200':'#eeff41','A400':'#c6ff00','A700':'#aeea00' } },
|
|
'Jaune': { base: '#ffeb3b', shades: { '50':'#fffde7','100':'#fff9c4','200':'#fff59d','300':'#fff176','400':'#ffee58','500':'#ffeb3b','600':'#fdd835','700':'#fbc02d','800':'#f9a825','900':'#f57f17','A100':'#ffff8d','A200':'#ffff00','A400':'#ffea00','A700':'#ffd600' } },
|
|
'Ambre': { base: '#ffc107', shades: { '50':'#fff8e1','100':'#ffecb3','200':'#ffe082','300':'#ffd54f','400':'#ffca28','500':'#ffc107','600':'#ffb300','700':'#ffa000','800':'#ff8f00','900':'#ff6f00','A100':'#ffe57f','A200':'#ffd740','A400':'#ffc400','A700':'#ffab00' } },
|
|
'Orange': { base: '#ff9800', shades: { '50':'#fff3e0','100':'#ffe0b2','200':'#ffcc80','300':'#ffb74d','400':'#ffa726','500':'#ff9800','600':'#fb8c00','700':'#f57c00','800':'#ef6c00','900':'#e65100','A100':'#ffd180','A200':'#ffab40','A400':'#ff9100','A700':'#ff6d00' } },
|
|
'Orange foncé': { base: '#ff5722', shades: { '50':'#fbe9e7','100':'#ffccbc','200':'#ffab91','300':'#ff8a65','400':'#ff7043','500':'#ff5722','600':'#f4511e','700':'#e64a19','800':'#d84315','900':'#bf360c','A100':'#ff9e80','A200':'#ff6e40','A400':'#ff3d00','A700':'#dd2c00' } },
|
|
'Marron': { base: '#795548', shades: { '50':'#efebe9','100':'#d7ccc8','200':'#bcaaa4','300':'#a1887f','400':'#8d6e63','500':'#795548','600':'#6d4c41','700':'#5d4037','800':'#4e342e','900':'#3e2723' } },
|
|
'Gris': { base: '#9e9e9e', shades: { '50':'#fafafa','100':'#f5f5f5','200':'#eeeeee','300':'#e0e0e0','400':'#bdbdbd','500':'#9e9e9e','600':'#757575','700':'#616161','800':'#424242','900':'#212121' } },
|
|
'Gris bleu': { base: '#607d8b', shades: { '50':'#eceff1','100':'#cfd8dc','200':'#b0bec5','300':'#90a4ae','400':'#78909c','500':'#607d8b','600':'#546e7a','700':'#455a64','800':'#37474f','900':'#263238' } },
|
|
};
|
|
|
|
/* Retourne true si la couleur hex est claire (pour adapter la couleur du texte) */
|
|
function isLightColor(hex) {
|
|
const r = parseInt(hex.slice(1,3),16);
|
|
const g = parseInt(hex.slice(3,5),16);
|
|
const b = parseInt(hex.slice(5,7),16);
|
|
return (r * 299 + g * 587 + b * 114) / 1000 > 155;
|
|
}
|
|
|
|
/* Retrouve la famille et la teinte d'un hex donné */
|
|
function findInPalette(hex) {
|
|
const h = hex.toLowerCase();
|
|
for (const [family, { shades }] of Object.entries(MD_PALETTE)) {
|
|
for (const [shade, color] of Object.entries(shades)) {
|
|
if (color.toLowerCase() === h) return { family, shade };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/* 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);
|
|
const g = parseInt(hex.slice(3,5),16);
|
|
const b = parseInt(hex.slice(5,7),16);
|
|
const lr = Math.round(r + (255 - r) * factor);
|
|
const lg = Math.round(g + (255 - g) * factor);
|
|
const lb = Math.round(b + (255 - b) * factor);
|
|
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 }) {
|
|
const isActive = (p) =>
|
|
p.interets === interets && p.capital === capital && p.cashback === cashback;
|
|
|
|
return (
|
|
<div style={{ marginBottom: 24 }}>
|
|
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', fontWeight: 600, color: 'var(--text)' }}>
|
|
Palettes suggérées
|
|
</p>
|
|
<div className="palette-grid">
|
|
{SUGGESTED_PALETTES.map((p) => (
|
|
<button
|
|
key={p.name}
|
|
type="button"
|
|
className={`palette-card${isActive(p) ? ' active' : ''}`}
|
|
onClick={() => onSelect(p)}
|
|
title={p.name}
|
|
>
|
|
<div className="palette-swatches">
|
|
<span className="palette-swatch" style={{ backgroundColor: p.interets }} />
|
|
<span className="palette-swatch" style={{ backgroundColor: p.capital }} />
|
|
<span className="palette-swatch" style={{ backgroundColor: p.cashback }} />
|
|
</div>
|
|
<span className="palette-name">{p.name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChartColorPicker({ value, onChange }) {
|
|
const found = findInPalette(value);
|
|
const [selectedFamily, setSelectedFamily] = useState(found?.family || 'Bleu');
|
|
|
|
// Sync la famille quand value change depuis l'extérieur (sélection palette)
|
|
useEffect(() => {
|
|
const f = findInPalette(value);
|
|
if (f) setSelectedFamily(f.family);
|
|
}, [value]);
|
|
|
|
const familyData = MD_PALETTE[selectedFamily];
|
|
const shadeEntries = familyData ? Object.entries(familyData.shades) : [];
|
|
|
|
function handleFamilyChange(e) {
|
|
const fam = e.target.value;
|
|
setSelectedFamily(fam);
|
|
// Auto-select shade 500 (or first available) when changing family
|
|
const shades = MD_PALETTE[fam]?.shades || {};
|
|
const target = shades['500'] || Object.values(shades)[5] || Object.values(shades)[0];
|
|
if (target) onChange(target);
|
|
}
|
|
|
|
const textColor = isLightColor(value) ? '#212121' : '#ffffff';
|
|
|
|
return (
|
|
<div className="chart-color-picker">
|
|
<div className="color-family-select-row">
|
|
<span className="color-family-indicator" style={{ backgroundColor: familyData?.base || value }} />
|
|
<select value={selectedFamily} onChange={handleFamilyChange}>
|
|
{Object.keys(MD_PALETTE).map(name => (
|
|
<option key={name} value={name}>{name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="color-shade-grid">
|
|
{shadeEntries.map(([shade, hex]) => (
|
|
<button
|
|
key={shade}
|
|
type="button"
|
|
title={`${selectedFamily} ${shade} — ${hex}`}
|
|
className={`color-shade-btn${value.toLowerCase() === hex.toLowerCase() ? ' selected' : ''}`}
|
|
style={{ backgroundColor: hex }}
|
|
onClick={() => onChange(hex)}
|
|
>
|
|
<span className="color-shade-label" style={{ color: isLightColor(hex) ? '#000' : '#fff' }}>
|
|
{shade}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="color-preview-pair">
|
|
<div className="color-preview-bar" style={{ backgroundColor: value, color: textColor }}>
|
|
<span className="color-preview-label">Reçu</span>
|
|
<span className="color-preview-hex">{value.toUpperCase()}</span>
|
|
</div>
|
|
<div className="color-preview-bar" style={{ backgroundColor: lightenColor(value), color: isLightColor(lightenColor(value)) ? '#333' : '#fff' }}>
|
|
<span className="color-preview-label">Projeté</span>
|
|
<span className="color-preview-hex">{lightenColor(value).toUpperCase()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AppearanceSection() {
|
|
const { mode, setMode } = useTheme();
|
|
const { fontScale, setFontScale, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback } = useUi();
|
|
const [libIcons, setLibIcons] = useState({});
|
|
|
|
useEffect(() => {
|
|
api.get('/icons').then(rows => {
|
|
const m = {};
|
|
rows.forEach(r => { m[r.name] = r.filename; });
|
|
setLibIcons(m);
|
|
}).catch(() => {});
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<div className="card">
|
|
<h3 style={{ margin: '0 0 4px' }}>Thème</h3>
|
|
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
|
Choisissez le thème visuel de l'application.
|
|
</p>
|
|
<div className="pref-options">
|
|
{THEMES.map((t) => (
|
|
<button key={t.mode} type="button"
|
|
className={`pref-option${mode === t.mode ? ' active' : ''}`}
|
|
onClick={() => setMode(t.mode)} aria-pressed={mode === t.mode}>
|
|
{t.preview}
|
|
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 4 }}>{t.label}</span>
|
|
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{t.desc}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h3 style={{ margin: '0 0 4px' }}>Taille du texte</h3>
|
|
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
|
Le niveau <strong>Grand</strong> est recommandé pour les personnes malvoyantes.
|
|
Les niveaux inférieurs permettent d'afficher plus de données à l'écran.
|
|
</p>
|
|
<div className="pref-options">
|
|
{FONTS.map((f) => (
|
|
<button key={f.scale} type="button"
|
|
className={`pref-option${fontScale === f.scale ? ' active' : ''}`}
|
|
onClick={() => setFontScale(f.scale)} aria-pressed={fontScale === f.scale}>
|
|
<div style={{
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
|
|
width: '100%', padding: '8px 0 4px',
|
|
borderBottom: '1px solid var(--border)', marginBottom: 4,
|
|
}}>
|
|
<span className="font-preview" style={{ fontSize: f.sizes.body }}>Aa — {f.label}</span>
|
|
<span style={{ fontSize: f.sizes.table, color: 'var(--text-muted)' }}>
|
|
Tableau {f.sizes.table}px · Corps {f.sizes.body}px
|
|
</span>
|
|
</div>
|
|
<span style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', marginTop: 2 }}>{f.label}</span>
|
|
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>{f.desc}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h3 style={{ margin: '0 0 4px' }}>Couleurs des graphiques</h3>
|
|
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
|
|
Choisissez une palette ci-dessous ou personnalisez chaque série individuellement.
|
|
</p>
|
|
<PaletteSelector
|
|
interets={chartInterets}
|
|
capital={chartCapital}
|
|
cashback={chartCashback}
|
|
onSelect={(p) => { setChartInterets(p.interets); setChartCapital(p.capital); setChartCashback(p.cashback); }}
|
|
/>
|
|
<div className="chart-color-row">
|
|
<div className="chart-color-item">
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
|
|
<AppIcon filename={libIcons.interets} size={33} />
|
|
Intérêts
|
|
</label>
|
|
<ChartColorPicker value={chartInterets} onChange={setChartInterets} />
|
|
</div>
|
|
<div className="chart-color-item">
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
|
|
<AppIcon filename={libIcons.capital} size={33} />
|
|
Capital
|
|
</label>
|
|
<ChartColorPicker value={chartCapital} onChange={setChartCapital} />
|
|
</div>
|
|
<div className="chart-color-item">
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '1.1rem', fontWeight: 600 }}>
|
|
<AppIcon filename={libIcons.cashback} size={33} />
|
|
Cashback
|
|
</label>
|
|
<ChartColorPicker value={chartCashback} onChange={setChartCashback} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ── Icônes nav ──────────────────────────────────────────────── */
|