Ajouter components/ui-kit.jsx
This commit is contained in:
@@ -0,0 +1,656 @@
|
|||||||
|
/* ============================================================
|
||||||
|
ui-kit.jsx
|
||||||
|
Composants haute-fid Gruvbox Seventies.
|
||||||
|
Tout est purement décoratif/interactif côté composant.
|
||||||
|
Effets : transparence (glass), hover glow, click 3D, tooltips.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
const { useState, useRef, useEffect } = React;
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Icônes — Font Awesome 6 Free.
|
||||||
|
Mapping nom logique → classe FA. Le CSS de FA est chargé en CDN
|
||||||
|
dans le <head>. Le composant garde la MÊME API qu'avant (name,
|
||||||
|
size, style) pour ne rien casser ailleurs.
|
||||||
|
============================================================ */
|
||||||
|
const ICON_MAP = {
|
||||||
|
cpu: 'microchip',
|
||||||
|
memory: 'memory',
|
||||||
|
disk: 'hard-drive',
|
||||||
|
network: 'network-wired',
|
||||||
|
clock: 'clock',
|
||||||
|
grid: 'table-cells',
|
||||||
|
list: 'list',
|
||||||
|
cog: 'gear',
|
||||||
|
alert: 'triangle-exclamation',
|
||||||
|
bell: 'bell',
|
||||||
|
server: 'server',
|
||||||
|
chart: 'chart-line',
|
||||||
|
bars: 'chart-simple',
|
||||||
|
terminal: 'terminal',
|
||||||
|
refresh: 'arrows-rotate',
|
||||||
|
play: 'play',
|
||||||
|
pause: 'pause',
|
||||||
|
power: 'power-off',
|
||||||
|
sun: 'sun',
|
||||||
|
moon: 'moon',
|
||||||
|
search: 'magnifying-glass',
|
||||||
|
close: 'xmark',
|
||||||
|
chevR: 'chevron-right',
|
||||||
|
chevL: 'chevron-left',
|
||||||
|
chevD: 'chevron-down',
|
||||||
|
chevU: 'chevron-up',
|
||||||
|
plus: 'plus',
|
||||||
|
filter: 'filter',
|
||||||
|
download: 'download',
|
||||||
|
folder: 'folder',
|
||||||
|
node: 'circle-nodes',
|
||||||
|
user: 'user',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = ({ name, size = 16, style }) => {
|
||||||
|
const fa = ICON_MAP[name] || 'circle-question';
|
||||||
|
return (
|
||||||
|
<i className={`fa-solid fa-${fa}`} aria-hidden="true" style={{
|
||||||
|
fontSize: size,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
lineHeight: `${size}px`,
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flex: '0 0 auto',
|
||||||
|
color: 'currentColor',
|
||||||
|
...style,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Tooltip — apparaît au hover après 300ms, position auto.
|
||||||
|
============================================================ */
|
||||||
|
function Tooltip({ children, label, side = 'top' }) {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const t = useRef();
|
||||||
|
const onEnter = () => { t.current = setTimeout(() => setShow(true), 280); };
|
||||||
|
const onLeave = () => { clearTimeout(t.current); setShow(false); };
|
||||||
|
const sides = {
|
||||||
|
top: { bottom: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
|
||||||
|
bottom: { top: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
|
||||||
|
left: { right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
|
||||||
|
right: { left: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span style={{ position: 'relative', display: 'inline-flex' }}
|
||||||
|
onMouseEnter={onEnter} onMouseLeave={onLeave}>
|
||||||
|
{children}
|
||||||
|
{show && (
|
||||||
|
<span className="glass-strong" style={{
|
||||||
|
position: 'absolute', ...sides[side],
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12, lineHeight: 1.3,
|
||||||
|
color: 'var(--ink-1)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
boxShadow: 'var(--shadow-2)',
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
}}>{label}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
IconButton — bouton icône seul + tooltip obligatoire.
|
||||||
|
============================================================ */
|
||||||
|
function IconButton({ icon, label, onClick, active, danger, size = 34, primary }) {
|
||||||
|
const bg = active ? 'var(--accent-tint)'
|
||||||
|
: primary ? 'var(--accent)'
|
||||||
|
: 'var(--bg-3)';
|
||||||
|
const fg = active ? 'var(--accent)'
|
||||||
|
: primary ? 'var(--bg-1)'
|
||||||
|
: danger ? 'var(--err)'
|
||||||
|
: 'var(--ink-2)';
|
||||||
|
const bd = active ? 'var(--accent-soft)' : 'var(--border-2)';
|
||||||
|
return (
|
||||||
|
<Tooltip label={label}>
|
||||||
|
<button onClick={onClick} className="interactive" style={{
|
||||||
|
width: size, height: size,
|
||||||
|
background: bg,
|
||||||
|
color: fg,
|
||||||
|
border: `1px solid ${bd}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 0, cursor: 'pointer',
|
||||||
|
boxShadow: primary ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
|
||||||
|
}}>
|
||||||
|
<Icon name={icon} size={Math.round(size * 0.5)} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Toggle on/off — switch tactile avec glow accent quand ON
|
||||||
|
============================================================ */
|
||||||
|
function Toggle({ on, onChange, label, icon }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{icon && <Icon name={icon} size={14} style={{ color: on ? 'var(--accent)' : 'var(--ink-3)' }} />}
|
||||||
|
{label && <span className="label" style={{ color: on ? 'var(--ink-1)' : 'var(--ink-3)' }}>{label}</span>}
|
||||||
|
<button onClick={() => onChange(!on)} className="interactive" style={{
|
||||||
|
width: 42, height: 22, borderRadius: 12,
|
||||||
|
background: on ? 'var(--accent)' : 'var(--bg-4)',
|
||||||
|
border: `1px solid ${on ? 'var(--accent-soft)' : 'var(--border-2)'}`,
|
||||||
|
boxShadow: on ? `0 0 10px var(--accent-glow), var(--shadow-1)` : 'var(--shadow-press)',
|
||||||
|
position: 'relative', cursor: 'pointer', padding: 0,
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 1, left: on ? 21 : 1,
|
||||||
|
width: 18, height: 18, borderRadius: '50%',
|
||||||
|
background: on ? 'var(--bg-1)' : 'var(--ink-2)',
|
||||||
|
transition: 'left .18s cubic-bezier(.5,.2,.3,1.3), background .15s',
|
||||||
|
boxShadow: 'var(--shadow-1)',
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Status LED — pastille pulsante (effet halo si critique)
|
||||||
|
============================================================ */
|
||||||
|
function StatusLed({ status = 'ok', size = 10, pulse }) {
|
||||||
|
const map = {
|
||||||
|
ok: { c: 'var(--ok)', g: 'var(--ok-glow)' },
|
||||||
|
warn: { c: 'var(--warn)', g: 'var(--warn-glow)' },
|
||||||
|
err: { c: 'var(--err)', g: 'var(--err-glow)' },
|
||||||
|
off: { c: 'var(--ink-4)', g: 'transparent' },
|
||||||
|
info: { c: 'var(--info)', g: 'var(--info-glow)' },
|
||||||
|
};
|
||||||
|
const { c, g } = map[status];
|
||||||
|
const id = `pulse-${status}-${size}`;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pulse && (
|
||||||
|
<style>{`@keyframes ${id} { 0%{box-shadow:0 0 0 0 ${g}} 70%{box-shadow:0 0 0 6px transparent} 100%{box-shadow:0 0 0 0 transparent} }`}</style>
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: size, height: size,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: c,
|
||||||
|
boxShadow: status === 'off' ? 'none' : `0 0 6px ${g}, inset 0 0 2px rgba(0,0,0,0.3)`,
|
||||||
|
animation: pulse ? `${id} 1.8s ease-out infinite` : 'none',
|
||||||
|
flex: '0 0 auto',
|
||||||
|
}} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
BatteryGauge — jauge horizontale style batterie
|
||||||
|
- Pas de bandes (couleur unie + léger gloss interne)
|
||||||
|
- Pas de graduations verticales
|
||||||
|
- Hover : glow lumineux dans la couleur de la jauge
|
||||||
|
- Mode compact : label [bar] valeur sur une seule ligne
|
||||||
|
============================================================ */
|
||||||
|
function BatteryGauge({ value = 60, label, max = 100, unit = '%', warnAt = 70, errAt = 90, height = 22, compact = false, color: colorOverride, icon }) {
|
||||||
|
const pct = Math.max(0, Math.min(100, (value / max) * 100));
|
||||||
|
const color = colorOverride
|
||||||
|
|| (pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)');
|
||||||
|
const glowVar = pct >= errAt ? 'var(--err-glow)'
|
||||||
|
: pct >= warnAt ? 'var(--warn-glow)'
|
||||||
|
: 'var(--ok-glow)';
|
||||||
|
|
||||||
|
// Variante compacte : label [bar] valeur sur une seule ligne
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="bg-hover" style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10, minWidth: 0,
|
||||||
|
'--bg-glow': glowVar,
|
||||||
|
}}>
|
||||||
|
{(icon || label) && (
|
||||||
|
<span style={{
|
||||||
|
flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
minWidth: 90,
|
||||||
|
}}>
|
||||||
|
{icon && <Icon name={icon} size={12} style={{ color: 'var(--ink-3)' }} />}
|
||||||
|
{label && <span className="label" style={{ fontSize: 11 }}>{label}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="bg-bar" style={{
|
||||||
|
flex: 1, height: 12, borderRadius: 3,
|
||||||
|
background: 'var(--bg-1)',
|
||||||
|
border: '1px solid var(--border-2)',
|
||||||
|
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
|
||||||
|
overflow: 'hidden', position: 'relative',
|
||||||
|
transition: 'border-color .2s',
|
||||||
|
}}>
|
||||||
|
<div className="bg-fill" style={{
|
||||||
|
position: 'absolute', top: 1, left: 1, bottom: 1,
|
||||||
|
width: `calc((100% - 2px) * ${pct / 100})`,
|
||||||
|
background: color,
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<span className="mono" style={{
|
||||||
|
flex: '0 0 auto', fontSize: 13,
|
||||||
|
color: 'var(--ink-1)', minWidth: 52, textAlign: 'right',
|
||||||
|
}}>
|
||||||
|
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-hover" style={{
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0,
|
||||||
|
'--bg-glow': glowVar,
|
||||||
|
}}>
|
||||||
|
{label && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
|
<span className="label">{label}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 13, color: 'var(--ink-1)' }}>
|
||||||
|
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-bar" style={{
|
||||||
|
position: 'relative',
|
||||||
|
height, borderRadius: 4,
|
||||||
|
background: 'var(--bg-1)',
|
||||||
|
border: '1px solid var(--border-2)',
|
||||||
|
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'border-color .2s',
|
||||||
|
}}>
|
||||||
|
<div className="bg-fill" style={{
|
||||||
|
position: 'absolute', top: 1, left: 1, bottom: 1,
|
||||||
|
width: `calc((100% - 2px) * ${pct / 100})`,
|
||||||
|
background: color,
|
||||||
|
borderRadius: 3,
|
||||||
|
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
|
||||||
|
}} />
|
||||||
|
{/* Gloss interne très léger (un seul highlight haut, pas de bande inférieure) */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 1, left: 1, right: 1, height: '40%',
|
||||||
|
background: 'linear-gradient(180deg, rgba(255,255,255,0.18), transparent)',
|
||||||
|
borderRadius: '3px 3px 0 0',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
RadialGauge — jauge ronde, version épurée
|
||||||
|
============================================================ */
|
||||||
|
function RadialGauge({ value = 64, label, size = 120, warnAt = 70, errAt = 90 }) {
|
||||||
|
const pct = Math.max(0, Math.min(100, value));
|
||||||
|
const color = pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)';
|
||||||
|
const glow = pct >= errAt ? 'var(--err-glow)' : pct >= warnAt ? 'var(--warn-glow)' : 'var(--ok-glow)';
|
||||||
|
const r = size / 2 - 10;
|
||||||
|
const cx = size / 2;
|
||||||
|
const cy = size / 2 + 6;
|
||||||
|
const circ = Math.PI * r;
|
||||||
|
const offset = circ - (pct / 100) * circ;
|
||||||
|
return (
|
||||||
|
<div className="gauge-hover" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||||
|
<svg width={size} height={size * 0.72} viewBox={`0 0 ${size} ${size * 0.8}`}>
|
||||||
|
<defs>
|
||||||
|
<filter id={`glow-${label}`} x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="2.5" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
{/* arc background */}
|
||||||
|
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||||
|
fill="none" stroke="var(--bg-4)" strokeWidth="6" strokeLinecap="round" />
|
||||||
|
{/* arc value glow */}
|
||||||
|
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||||
|
fill="none" stroke={color} strokeWidth="8" strokeLinecap="round"
|
||||||
|
strokeDasharray={circ} strokeDashoffset={offset}
|
||||||
|
filter={`url(#glow-${label})`} opacity="0.7" />
|
||||||
|
{/* arc value crisp */}
|
||||||
|
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||||
|
fill="none" stroke={color} strokeWidth="5" strokeLinecap="round"
|
||||||
|
strokeDasharray={circ} strokeDashoffset={offset}
|
||||||
|
style={{ transition: 'stroke-dashoffset .5s cubic-bezier(.3,.6,.3,1)' }} />
|
||||||
|
</svg>
|
||||||
|
<div style={{ marginTop: -10, textAlign: 'center' }}>
|
||||||
|
<div className="mono" style={{ fontSize: size * 0.22, fontWeight: 600, color: 'var(--ink-1)', lineHeight: 1 }}>
|
||||||
|
{value}<span style={{ fontSize: '0.55em', color: 'var(--ink-3)', marginLeft: 2 }}>%</span>
|
||||||
|
</div>
|
||||||
|
{label && <div className="label" style={{ marginTop: 2 }}>{label}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
BigRadialGauge — la grande jauge cockpit "santé système"
|
||||||
|
============================================================ */
|
||||||
|
function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
|
||||||
|
const size = 320;
|
||||||
|
const r = 130;
|
||||||
|
const cx = size / 2;
|
||||||
|
const cy = size / 2 + 30;
|
||||||
|
const circ = Math.PI * r;
|
||||||
|
const offset = circ - (value / 100) * circ;
|
||||||
|
const color = value >= 80 ? 'var(--ok)' : value >= 50 ? 'var(--warn)' : 'var(--err)';
|
||||||
|
return (
|
||||||
|
<div className="gauge-hover" style={{ position: 'relative', width: size, height: size * 0.78 }}>
|
||||||
|
<svg width={size} height={size * 0.85}>
|
||||||
|
<defs>
|
||||||
|
<filter id="biggauge-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="4" />
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="biggauge-grad" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stopColor={color} stopOpacity="0.7"/>
|
||||||
|
<stop offset="1" stopColor={color}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{/* tics */}
|
||||||
|
{Array.from({ length: 21 }).map((_, i) => {
|
||||||
|
const a = Math.PI - (i / 20) * Math.PI;
|
||||||
|
const major = i % 5 === 0;
|
||||||
|
const inner = major ? r + 8 : r + 11;
|
||||||
|
const outer = major ? r + 20 : r + 15;
|
||||||
|
return <line key={i}
|
||||||
|
x1={cx + Math.cos(a) * inner} y1={cy - Math.sin(a) * inner}
|
||||||
|
x2={cx + Math.cos(a) * outer} y2={cy - Math.sin(a) * outer}
|
||||||
|
stroke={major ? 'var(--ink-3)' : 'var(--ink-4)'} strokeWidth={major ? 1.5 : 0.8}
|
||||||
|
/>;
|
||||||
|
})}
|
||||||
|
{[0, 50, 100].map(v => {
|
||||||
|
const a = Math.PI - (v / 100) * Math.PI;
|
||||||
|
const x = cx + Math.cos(a) * (r + 32);
|
||||||
|
const y = cy - Math.sin(a) * (r + 32) + 4;
|
||||||
|
return <text key={v} x={x} y={y} textAnchor="middle"
|
||||||
|
fontFamily="JetBrains Mono" fontSize="11"
|
||||||
|
fill="var(--ink-3)">{v}</text>;
|
||||||
|
})}
|
||||||
|
{/* arc bg */}
|
||||||
|
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||||
|
fill="none" stroke="var(--bg-4)" strokeWidth="10" strokeLinecap="round" />
|
||||||
|
{/* arc value glow */}
|
||||||
|
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||||
|
fill="none" stroke={color} strokeWidth="14" strokeLinecap="round"
|
||||||
|
strokeDasharray={circ} strokeDashoffset={offset}
|
||||||
|
filter="url(#biggauge-glow)" opacity="0.55" />
|
||||||
|
{/* arc value */}
|
||||||
|
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||||
|
fill="none" stroke="url(#biggauge-grad)" strokeWidth="9" strokeLinecap="round"
|
||||||
|
strokeDasharray={circ} strokeDashoffset={offset}
|
||||||
|
style={{ transition: 'stroke-dashoffset .8s cubic-bezier(.3,.6,.3,1)' }} />
|
||||||
|
{/* needle */}
|
||||||
|
<line x1={cx} y1={cy}
|
||||||
|
x2={cx + Math.cos(Math.PI - (value / 100) * Math.PI) * (r - 14)}
|
||||||
|
y2={cy - Math.sin(Math.PI - (value / 100) * Math.PI) * (r - 14)}
|
||||||
|
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
||||||
|
style={{ filter: 'drop-shadow(0 0 4px var(--accent-glow))' }} />
|
||||||
|
<circle cx={cx} cy={cy} r="9" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
|
||||||
|
<circle cx={cx} cy={cy} r="3" fill="var(--accent)" />
|
||||||
|
</svg>
|
||||||
|
<div style={{ position: 'absolute', bottom: 12, left: 0, right: 0, textAlign: 'center' }}>
|
||||||
|
<div className="mono" style={{
|
||||||
|
fontSize: 64, fontWeight: 700, lineHeight: 1,
|
||||||
|
color: 'var(--ink-1)',
|
||||||
|
textShadow: `0 0 20px ${color}33`,
|
||||||
|
}}>{value}</div>
|
||||||
|
<div className="label" style={{ marginTop: 6 }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Popup — modale glassmorphism centrée + bouton fermer
|
||||||
|
============================================================ */
|
||||||
|
function Popup({ open, onClose, title, children, footer, width = 460 }) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 100,
|
||||||
|
background: 'rgba(0,0,0,0.45)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
animation: 'fadein .2s ease-out',
|
||||||
|
}} onClick={onClose}>
|
||||||
|
<style>{`
|
||||||
|
@keyframes fadein { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
@keyframes popin { from { opacity: 0; transform: translateY(8px) scale(.98) } to { opacity: 1; transform: translateY(0) scale(1) } }
|
||||||
|
`}</style>
|
||||||
|
<div className="glass-strong" onClick={e => e.stopPropagation()} style={{
|
||||||
|
width, maxWidth: '90%',
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: 'var(--shadow-3)',
|
||||||
|
animation: 'popin .25s cubic-bezier(.3,.7,.3,1.2)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--border-1)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
background: 'var(--bg-3)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 15, color: 'var(--ink-1)' }}>{title}</div>
|
||||||
|
<IconButton icon="close" label="Fermer" onClick={onClose} size={28} />
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 18 }}>{children}</div>
|
||||||
|
{footer && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderTop: '1px solid var(--border-1)',
|
||||||
|
background: 'var(--bg-2)',
|
||||||
|
display: 'flex', justifyContent: 'flex-end', gap: 8,
|
||||||
|
}}>{footer}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Button — bouton classique avec variantes
|
||||||
|
============================================================ */
|
||||||
|
function Button({ children, icon, onClick, variant = 'default', size = 'md' }) {
|
||||||
|
const sizes = {
|
||||||
|
sm: { padding: '5px 10px', fontSize: 12, h: 28 },
|
||||||
|
md: { padding: '7px 14px', fontSize: 13, h: 34 },
|
||||||
|
lg: { padding: '10px 18px', fontSize: 14, h: 40 },
|
||||||
|
}[size];
|
||||||
|
const variants = {
|
||||||
|
default: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
|
||||||
|
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
|
||||||
|
ghost: { bg: 'transparent', fg: 'var(--ink-2)', bd: 'var(--border-2)' },
|
||||||
|
danger: { bg: 'var(--bg-3)', fg: 'var(--err)', bd: 'var(--err)' },
|
||||||
|
}[variant];
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className="interactive" style={{
|
||||||
|
height: sizes.h,
|
||||||
|
padding: sizes.padding,
|
||||||
|
background: variants.bg,
|
||||||
|
color: variants.fg,
|
||||||
|
border: `1px solid ${variants.bd}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||||
|
fontFamily: 'inherit', fontSize: sizes.fontSize, fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: variant === 'primary' ? '0 2px 8px var(--accent-glow)' : 'var(--shadow-1)',
|
||||||
|
}}>
|
||||||
|
{icon && <Icon name={icon} size={14} />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
TreeNav — arbre dépliable avec icône en tête (style B)
|
||||||
|
============================================================ */
|
||||||
|
function TreeNav({ groups, activeId, onSelect }) {
|
||||||
|
const [open, setOpen] = useState(() =>
|
||||||
|
Object.fromEntries(groups.map(g => [g.id, g.open !== false]))
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{groups.map(g => (
|
||||||
|
<div key={g.id}>
|
||||||
|
<div className="interactive" onClick={() => setOpen({ ...open, [g.id]: !open[g.id] })}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '7px 8px', borderRadius: 6,
|
||||||
|
color: 'var(--ink-2)',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<Icon name="chevR" size={12} style={{
|
||||||
|
transform: open[g.id] ? 'rotate(90deg)' : 'rotate(0)',
|
||||||
|
transition: 'transform .15s',
|
||||||
|
color: 'var(--ink-3)',
|
||||||
|
}} />
|
||||||
|
<Icon name={g.icon || 'folder'} size={15} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{g.label}</span>
|
||||||
|
{g.count != null && (
|
||||||
|
<span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>
|
||||||
|
{g.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{open[g.id] && (
|
||||||
|
<div style={{ marginLeft: 18, marginTop: 2, display: 'flex', flexDirection: 'column', gap: 1, paddingLeft: 8, borderLeft: '1px dashed var(--border-1)' }}>
|
||||||
|
{g.children.map(c => {
|
||||||
|
const active = c.id === activeId;
|
||||||
|
return (
|
||||||
|
<div key={c.id} className="interactive" onClick={() => onSelect && onSelect(c.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '6px 10px', borderRadius: 6,
|
||||||
|
background: active ? 'var(--accent-tint)' : 'transparent',
|
||||||
|
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
|
||||||
|
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||||
|
marginLeft: active ? 0 : 2,
|
||||||
|
fontSize: 12.5,
|
||||||
|
}}>
|
||||||
|
<StatusLed status={c.status} size={8} pulse={c.status === 'err'} />
|
||||||
|
<span className="mono" style={{ fontSize: 11.5, flex: 1 }}>{c.label}</span>
|
||||||
|
{c.meta && <span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>{c.meta}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Sparkline pour les KPI
|
||||||
|
============================================================ */
|
||||||
|
function Sparkline({ points = [], color = 'var(--accent)', h = 28 }) {
|
||||||
|
const w = 100;
|
||||||
|
const max = Math.max(...points);
|
||||||
|
const min = Math.min(...points);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const step = w / (points.length - 1);
|
||||||
|
const path = points.map((p, i) =>
|
||||||
|
`${i === 0 ? 'M' : 'L'} ${(i * step).toFixed(1)} ${(h - 2 - ((p - min) / range) * (h - 4)).toFixed(1)}`
|
||||||
|
).join(' ');
|
||||||
|
const area = path + ` L ${w} ${h} L 0 ${h} Z`;
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
|
||||||
|
<path d={area} fill={color} opacity="0.12" />
|
||||||
|
<path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
LineChart — grand graph multi-séries
|
||||||
|
============================================================ */
|
||||||
|
function LineChart({ series, h = 200, labels }) {
|
||||||
|
const w = 600;
|
||||||
|
const padding = { l: 36, r: 12, t: 12, b: 24 };
|
||||||
|
const innerW = w - padding.l - padding.r;
|
||||||
|
const innerH = h - padding.t - padding.b;
|
||||||
|
const all = series.flatMap(s => s.points);
|
||||||
|
const max = Math.max(...all) * 1.1;
|
||||||
|
const min = 0;
|
||||||
|
const range = max - min;
|
||||||
|
const ptsCount = series[0].points.length;
|
||||||
|
const step = innerW / (ptsCount - 1);
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
|
||||||
|
{/* grid horizontal */}
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map(p => {
|
||||||
|
const y = padding.t + innerH * p;
|
||||||
|
const v = Math.round(max - range * p);
|
||||||
|
return (
|
||||||
|
<g key={p}>
|
||||||
|
<line x1={padding.l} x2={w - padding.r} y1={y} y2={y}
|
||||||
|
stroke="var(--border-1)" strokeWidth="1" strokeDasharray="3 5" />
|
||||||
|
<text x={padding.l - 6} y={y + 3} textAnchor="end"
|
||||||
|
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{v}</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* labels x */}
|
||||||
|
{labels && labels.map((lb, i) => (
|
||||||
|
i % Math.ceil(labels.length / 8) === 0 && (
|
||||||
|
<text key={i} x={padding.l + i * step} y={h - 6} textAnchor="middle"
|
||||||
|
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{lb}</text>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
{/* séries */}
|
||||||
|
{series.map((s, si) => {
|
||||||
|
const path = s.points.map((p, i) =>
|
||||||
|
`${i === 0 ? 'M' : 'L'} ${(padding.l + i * step).toFixed(1)} ${(padding.t + innerH - ((p - min) / range) * innerH).toFixed(1)}`
|
||||||
|
).join(' ');
|
||||||
|
const area = path + ` L ${padding.l + (ptsCount - 1) * step} ${padding.t + innerH} L ${padding.l} ${padding.t + innerH} Z`;
|
||||||
|
return (
|
||||||
|
<g key={si}>
|
||||||
|
<path d={area} fill={s.color} opacity="0.12" />
|
||||||
|
<path d={path} fill="none" stroke={s.color} strokeWidth="1.8"
|
||||||
|
strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expose */
|
||||||
|
Object.assign(window, {
|
||||||
|
Icon, Tooltip, IconButton, Toggle, StatusLed,
|
||||||
|
BatteryGauge, RadialGauge, BigRadialGauge,
|
||||||
|
Popup, Button, TreeNav, Sparkline, LineChart,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Effets hover sur les jauges (sans effet au clic) */
|
||||||
|
(function injectGaugeHoverStyles() {
|
||||||
|
if (document.getElementById('gauge-hover-styles')) return;
|
||||||
|
const s = document.createElement('style');
|
||||||
|
s.id = 'gauge-hover-styles';
|
||||||
|
s.textContent = `
|
||||||
|
.bg-hover:hover .bg-bar {
|
||||||
|
border-color: color-mix(in oklch, var(--accent) 60%, var(--border-3));
|
||||||
|
}
|
||||||
|
.bg-hover:hover .bg-fill {
|
||||||
|
box-shadow: 0 0 14px var(--bg-glow, var(--accent-glow));
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
.gauge-hover { transition: filter .2s; }
|
||||||
|
.gauge-hover:hover { filter: drop-shadow(0 0 8px var(--accent-glow)) brightness(1.08); }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user