499 lines
19 KiB
React
499 lines
19 KiB
React
import { useRef, useState, useCallback, useContext, createContext } from "react";
|
||||
|
|
import { PURPLE, PURPLE_LIGHT, EMOJI_OPTIONS } from "../../constants/index.js";
|
|||
|
|
|
|||
|
|
const DragContext = createContext(null);
|
|||
|
|
|
|||
|
|
// ── VCard ─────────────────────────────────────────────────────────────────────
|
|||
|
|
export function VCard({ children, style = {} }) {
|
|||
|
|
return (
|
|||
|
|
<div style={{
|
|||
|
|
borderRadius: 12, padding: "16px 18px",
|
|||
|
|
boxShadow: "none",
|
|||
|
|
...style,
|
|||
|
|
}}>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── VBadge ────────────────────────────────────────────────────────────────────
|
|||
|
|
export function VBadge({ label, color = PURPLE }) {
|
|||
|
|
return (
|
|||
|
|
<span style={{
|
|||
|
|
background: `${color}22`, color,
|
|||
|
|
border: `1px solid ${color}44`,
|
|||
|
|
borderRadius: 99, padding: "2px 10px",
|
|||
|
|
fontSize: 11, fontWeight: 700,
|
|||
|
|
}}>
|
|||
|
|
{label}
|
|||
|
|
</span>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── TabBtn ────────────────────────────────────────────────────────────────────
|
|||
|
|
export function TabBtn({ active, onClick, children, darkMode = true }) {
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
onClick={onClick}
|
|||
|
|
style={{
|
|||
|
|
padding: "8px 16px", border: "none", borderRadius: 8, cursor: "pointer",
|
|||
|
|
fontSize: 13, fontWeight: active ? 700 : 500,
|
|||
|
|
background: active ? `${PURPLE}22` : "transparent",
|
|||
|
|
color: active ? PURPLE_LIGHT : (darkMode ? "#94a3b8" : "#64748b"),
|
|||
|
|
borderBottom: active ? `2px solid ${PURPLE_LIGHT}` : "2px solid transparent",
|
|||
|
|
transition: "all 0.15s",
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{children}
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── VProgressBar ──────────────────────────────────────────────────────────────
|
|||
|
|
export function VProgressBar({ value, max, color = PURPLE }) {
|
|||
|
|
const p = Math.min((value / (max || 1)) * 100, 100);
|
|||
|
|
return (
|
|||
|
|
<div style={{ height: 8, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
|
|||
|
|
<div style={{
|
|||
|
|
height: "100%", width: `${p}%`, background: color,
|
|||
|
|
borderRadius: 99, transition: "width 0.5s ease",
|
|||
|
|
}} />
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── DonutChart ────────────────────────────────────────────────────────────────
|
|||
|
|
export function DonutChart({ segments, total, size = 120, label, centerVal }) {
|
|||
|
|
const strokeW = size * 0.155;
|
|||
|
|
const r = (size - strokeW) / 2 - 1;
|
|||
|
|
const cx = size / 2, cy = size / 2;
|
|||
|
|
const circ = 2 * Math.PI * r;
|
|||
|
|
const active = segments.filter((s) => s.val > 0);
|
|||
|
|
const GAP = active.length > 1 ? circ * 0.022 : 0;
|
|||
|
|
let off = 0;
|
|||
|
|
const slices = active.map((s) => {
|
|||
|
|
const raw = (s.val / total) * circ;
|
|||
|
|
const dash = Math.max(raw - GAP, 2);
|
|||
|
|
const sl = { ...s, dash, offset: off };
|
|||
|
|
off += raw;
|
|||
|
|
return sl;
|
|||
|
|
});
|
|||
|
|
const glowId = `dgl${size}`;
|
|||
|
|
return (
|
|||
|
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ overflow: "visible" }}>
|
|||
|
|
<defs>
|
|||
|
|
<filter id={glowId} x="-40%" y="-40%" width="180%" height="180%">
|
|||
|
|
<feGaussianBlur stdDeviation={size * 0.022} />
|
|||
|
|
</filter>
|
|||
|
|
</defs>
|
|||
|
|
|
|||
|
|
{/* Dark inner fill */}
|
|||
|
|
<circle cx={cx} cy={cy} r={r - strokeW / 2 + 1} fill="rgba(0,0,0,0.55)" />
|
|||
|
|
|
|||
|
|
{/* Track */}
|
|||
|
|
<circle cx={cx} cy={cy} r={r} fill="none"
|
|||
|
|
stroke="rgba(255,255,255,0.06)" strokeWidth={strokeW} />
|
|||
|
|
|
|||
|
|
{/* Glow pass */}
|
|||
|
|
{slices.map((s, i) => (
|
|||
|
|
<circle key={`g${i}`} cx={cx} cy={cy} r={r} fill="none"
|
|||
|
|
stroke={s.color} strokeWidth={strokeW * 1.5}
|
|||
|
|
strokeDasharray={`${s.dash} ${circ - s.dash}`}
|
|||
|
|
strokeDashoffset={-s.offset + circ / 4}
|
|||
|
|
strokeLinecap="round"
|
|||
|
|
opacity="0.18"
|
|||
|
|
filter={`url(#${glowId})`}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
|
|||
|
|
{/* Crisp segments */}
|
|||
|
|
{slices.map((s, i) => (
|
|||
|
|
<circle key={`c${i}`} cx={cx} cy={cy} r={r} fill="none"
|
|||
|
|
stroke={s.color} strokeWidth={strokeW}
|
|||
|
|
strokeDasharray={`${s.dash} ${circ - s.dash}`}
|
|||
|
|
strokeDashoffset={-s.offset + circ / 4}
|
|||
|
|
strokeLinecap="round"
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
|
|||
|
|
{/* Center label */}
|
|||
|
|
{label && (
|
|||
|
|
<text x={cx} y={cy - size * 0.1} fill="rgba(255,255,255,0.4)"
|
|||
|
|
fontSize={size * 0.09} textAnchor="middle" dominantBaseline="middle"
|
|||
|
|
fontFamily="inherit">
|
|||
|
|
{label}
|
|||
|
|
</text>
|
|||
|
|
)}
|
|||
|
|
{/* Center value */}
|
|||
|
|
{centerVal && (
|
|||
|
|
<text x={cx} y={cy + size * 0.09} fill="#fff"
|
|||
|
|
fontSize={size * 0.155} fontWeight="800" textAnchor="middle" dominantBaseline="middle"
|
|||
|
|
fontFamily="inherit">
|
|||
|
|
{centerVal}
|
|||
|
|
</text>
|
|||
|
|
)}
|
|||
|
|
</svg>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── SvgLineChart ──────────────────────────────────────────────────────────────
|
|||
|
|
export function SvgLineChart({ pts, lines, T, H = 180 }) {
|
|||
|
|
if (pts.length < 2) {
|
|||
|
|
return (
|
|||
|
|
<div style={{ color: T.muted, fontSize: 12, textAlign: "center", padding: "24px 0" }}>
|
|||
|
|
Minimaal 2 datapunten nodig
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
const W = 580, pl = 64, pr = 14, pt2 = 16, pb = 30;
|
|||
|
|
const iW = W - pl - pr, iH = H - pt2 - pb;
|
|||
|
|
const allVals = lines.flatMap((l) => pts.map((p) => p[l.key]));
|
|||
|
|
const mn = Math.min(0, ...allVals);
|
|||
|
|
const mx = Math.max(...allVals) * 1.08 || 1;
|
|||
|
|
const rng = mx - mn || 1;
|
|||
|
|
const xp = (i) => pl + (i / (pts.length - 1)) * iW;
|
|||
|
|
const yp = (v) => pt2 + (1 - (v - mn) / rng) * iH;
|
|||
|
|
const gc = T.bg === "#0f1117" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)";
|
|||
|
|
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div style={{ overflowX: "auto" }}>
|
|||
|
|
<svg viewBox={`0 0 ${W} ${H}`}
|
|||
|
|
style={{ width: "100%", minWidth: 340, height: "auto", display: "block" }}>
|
|||
|
|
<defs>
|
|||
|
|
{lines.map((l) => (
|
|||
|
|
<linearGradient key={l.key} id={`g_${l.key}`} x1="0" y1="0" x2="0" y2="1">
|
|||
|
|
<stop offset="0%" stopColor={l.color} stopOpacity="0.25" />
|
|||
|
|
<stop offset="100%" stopColor={l.color} stopOpacity="0.02" />
|
|||
|
|
</linearGradient>
|
|||
|
|
))}
|
|||
|
|
</defs>
|
|||
|
|
{[0, 0.25, 0.5, 0.75, 1].map((f) => {
|
|||
|
|
const gy = pt2 + (1 - f) * iH;
|
|||
|
|
const v = mn + f * rng;
|
|||
|
|
return (
|
|||
|
|
<g key={f}>
|
|||
|
|
<line x1={pl} y1={gy} x2={W - pr} y2={gy} stroke={gc} strokeWidth="1" />
|
|||
|
|
<text x={pl - 4} y={gy + 4} fill={T.muted} fontSize="8" textAnchor="end">
|
|||
|
|
{new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v)}
|
|||
|
|
</text>
|
|||
|
|
</g>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
{lines.map((l) => {
|
|||
|
|
const xs = pts.map((_, i) => xp(i));
|
|||
|
|
const ys = pts.map((p) => yp(p[l.key]));
|
|||
|
|
const ls = xs.map((x, i) => `${x},${ys[i]}`).join(" ");
|
|||
|
|
const ar = `${xs[0]},${pt2 + iH} ${ls} ${xs[xs.length - 1]},${pt2 + iH}`;
|
|||
|
|
return (
|
|||
|
|
<g key={l.key}>
|
|||
|
|
<polygon points={ar} fill={`url(#g_${l.key})`} />
|
|||
|
|
<polyline points={ls} fill="none" stroke={l.color} strokeWidth="2.5"
|
|||
|
|
strokeLinejoin="round" strokeLinecap="round" />
|
|||
|
|
{xs.map((x, i) => (
|
|||
|
|
<circle key={i} cx={x} cy={ys[i]} r="4"
|
|||
|
|
fill={l.color} stroke={T.card} strokeWidth="2" />
|
|||
|
|
))}
|
|||
|
|
</g>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
{pts.map((p, i) => {
|
|||
|
|
const [y, m, d] = (p.datum || "").split("-");
|
|||
|
|
const label = d ? `${d}-${m}-${y}` : p.datum;
|
|||
|
|
return (
|
|||
|
|
<text key={i} x={xp(i)} y={H - 8} fill={T.muted} fontSize="8" textAnchor="middle">
|
|||
|
|
{label}
|
|||
|
|
</text>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</svg>
|
|||
|
|
<div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 10 }}>
|
|||
|
|
{lines.map((l) => (
|
|||
|
|
<div key={l.key} style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|||
|
|
<div style={{ width: 18, height: 3, background: l.color, borderRadius: 2 }} />
|
|||
|
|
<div style={{ width: 8, height: 8, borderRadius: "50%", background: l.color }} />
|
|||
|
|
<span style={{ fontSize: 12, color: T.muted }}>{l.label}</span>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── EthIcon ───────────────────────────────────────────────────────────────────
|
|||
|
|
export function EthIcon({ size = 18, color = "#3b82f6" }) {
|
|||
|
|
const w = size * 0.6, h = size;
|
|||
|
|
return (
|
|||
|
|
<svg width={w} height={h} viewBox="0 0 60 100" xmlns="http://www.w3.org/2000/svg"
|
|||
|
|
style={{ display: "inline-block", verticalAlign: "middle" }}>
|
|||
|
|
<polygon points="30,2 58,50 30,38" fill={color} opacity="1" />
|
|||
|
|
<polygon points="2,50 30,2 30,38" fill={color} opacity="0.6" />
|
|||
|
|
<polygon points="30,62 58,50 30,38" fill={color} opacity="0.6" />
|
|||
|
|
<polygon points="2,50 30,38 30,62" fill={color} opacity="0.3" />
|
|||
|
|
<polygon points="30,98 58,50 30,62" fill={color} opacity="0.6" />
|
|||
|
|
<polygon points="2,50 30,62 30,98" fill={color} opacity="1" />
|
|||
|
|
</svg>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── BtcIcon ───────────────────────────────────────────────────────────────────
|
|||
|
|
export function BtcIcon({ size = 18, color = "#f59e0b" }) {
|
|||
|
|
return (
|
|||
|
|
<svg width={size} height={size} viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"
|
|||
|
|
style={{ display: "inline-block", verticalAlign: "middle" }}>
|
|||
|
|
<circle cx="32" cy="32" r="30" fill={color} />
|
|||
|
|
<text x="32" y="44" textAnchor="middle" fontSize="36" fontWeight="bold"
|
|||
|
|
fontFamily="Arial, sans-serif" fill="#fff">₿</text>
|
|||
|
|
</svg>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── GoldIcon ──────────────────────────────────────────────────────────────────
|
|||
|
|
export function GoldIcon({ size = 24 }) {
|
|||
|
|
return (
|
|||
|
|
<svg width={size} height={size * 0.72} viewBox="0 0 100 72" xmlns="http://www.w3.org/2000/svg"
|
|||
|
|
style={{ display: "inline-block", verticalAlign: "middle" }}>
|
|||
|
|
{/* Bovenkant */}
|
|||
|
|
<polygon points="18,4 82,4 100,20 0,20" fill="#fcd34d" />
|
|||
|
|
{/* Voorkant */}
|
|||
|
|
<rect x="0" y="20" width="100" height="40" fill="#f59e0b" />
|
|||
|
|
{/* Rechterkant */}
|
|||
|
|
<polygon points="100,20 100,60 82,72 82,36" fill="#d97706" />
|
|||
|
|
{/* Linkeronderkant */}
|
|||
|
|
<polygon points="0,60 0,20 18,4 18,44" fill="#fbbf24" />
|
|||
|
|
{/* Onderkant schaduw */}
|
|||
|
|
<polygon points="0,60 82,60 100,72 18,72" fill="#b45309" />
|
|||
|
|
{/* Streep highlight bovenkant */}
|
|||
|
|
<polygon points="28,8 72,8 88,18 12,18" fill="#fde68a" opacity="0.5" />
|
|||
|
|
{/* GOLD tekst */}
|
|||
|
|
<text x="50" y="46" textAnchor="middle" fontSize="18" fontWeight="800"
|
|||
|
|
fontFamily="Arial, sans-serif" fill="#92400e" letterSpacing="2">GOLD</text>
|
|||
|
|
</svg>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Modal primitieven ─────────────────────────────────────────────────────────
|
|||
|
|
export function Overlay({ children, onClose, zIndex = 400 }) {
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|||
|
|
style={{
|
|||
|
|
position: "fixed", inset: 0,
|
|||
|
|
background: "rgba(0,0,0,0.75)",
|
|||
|
|
zIndex,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function ModalBox({ children, maxWidth = 480 }) {
|
|||
|
|
const ref = useRef(null);
|
|||
|
|
const [pos, setPos] = useState(null);
|
|||
|
|
const dragState = useRef(null);
|
|||
|
|
|
|||
|
|
const startDrag = useCallback((e) => {
|
|||
|
|
if (e.button !== 0) return;
|
|||
|
|
e.preventDefault();
|
|||
|
|
const rect = ref.current.getBoundingClientRect();
|
|||
|
|
dragState.current = {
|
|||
|
|
startX: e.clientX,
|
|||
|
|
startY: e.clientY,
|
|||
|
|
startLeft: rect.left,
|
|||
|
|
startTop: rect.top,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onMove = (e) => {
|
|||
|
|
if (!dragState.current) return;
|
|||
|
|
setPos({
|
|||
|
|
x: dragState.current.startLeft + (e.clientX - dragState.current.startX),
|
|||
|
|
y: dragState.current.startTop + (e.clientY - dragState.current.startY),
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onUp = () => {
|
|||
|
|
dragState.current = null;
|
|||
|
|
window.removeEventListener("mousemove", onMove);
|
|||
|
|
window.removeEventListener("mouseup", onUp);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window.addEventListener("mousemove", onMove);
|
|||
|
|
window.addEventListener("mouseup", onUp);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const posStyle = pos
|
|||
|
|
? { left: pos.x, top: pos.y, transform: "none" }
|
|||
|
|
: { left: "50%", top: "50%", transform: "translate(-50%, -50%)" };
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<DragContext.Provider value={startDrag}>
|
|||
|
|
<div ref={ref} style={{
|
|||
|
|
position: "fixed",
|
|||
|
|
...posStyle,
|
|||
|
|
background: "#1a1d27",
|
|||
|
|
border: "1px solid rgba(255,255,255,0.07)",
|
|||
|
|
borderRadius: 18, width: "100%", maxWidth,
|
|||
|
|
boxShadow: "0 24px 64px rgba(0,0,0,0.5)",
|
|||
|
|
overflow: "hidden",
|
|||
|
|
}}>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
</DragContext.Provider>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function ModalHeader({ icon, iconBg, title, subtitle, subtitleColor, onClose, headerBg, T }) {
|
|||
|
|
const startDrag = useContext(DragContext);
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
onMouseDown={startDrag}
|
|||
|
|
style={{
|
|||
|
|
display: "flex", alignItems: "center",
|
|||
|
|
justifyContent: "space-between",
|
|||
|
|
padding: "18px 22px",
|
|||
|
|
borderBottom: `1px solid ${T.border}`,
|
|||
|
|
background: headerBg,
|
|||
|
|
cursor: "grab",
|
|||
|
|
userSelect: "none",
|
|||
|
|
}}>
|
|||
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|||
|
|
<div style={{
|
|||
|
|
width: 32, height: 32, borderRadius: 8,
|
|||
|
|
background: `${iconBg}33`,
|
|||
|
|
display: "flex", alignItems: "center",
|
|||
|
|
justifyContent: "center", fontSize: 16,
|
|||
|
|
}}>
|
|||
|
|
{icon}
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<div style={{ fontWeight: 700, fontSize: 15, color: T.text }}>{title}</div>
|
|||
|
|
<div style={{ fontSize: 11, color: subtitleColor, fontWeight: 600 }}>{subtitle}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<button onClick={onClose} onMouseDown={(e) => e.stopPropagation()} style={{
|
|||
|
|
background: "transparent", border: "none",
|
|||
|
|
color: T.muted, cursor: "pointer",
|
|||
|
|
fontSize: 22, lineHeight: 1, padding: 4,
|
|||
|
|
}}>×</button>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function ModalFooter({ children, T }) {
|
|||
|
|
return (
|
|||
|
|
<div style={{
|
|||
|
|
display: "flex", gap: 10,
|
|||
|
|
padding: "14px 22px",
|
|||
|
|
borderTop: `1px solid ${T.border}`,
|
|||
|
|
}}>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── AppIcon — rendert emoji-string of speciale SVG-iconen ────────────────────
|
|||
|
|
export function AppIcon({ value, fallback = "🏷", size = 20 }) {
|
|||
|
|
if (value === "__BTC__") return <BtcIcon size={size} color="#f59e0b" />;
|
|||
|
|
if (value === "__ETH__") return <EthIcon size={size} color="#627eea" />;
|
|||
|
|
if (value === "__GOLD__") return <GoldIcon size={size * 1.4} />;
|
|||
|
|
if (value) return <span style={{ fontSize: size, lineHeight: 1 }}>{value}</span>;
|
|||
|
|
return <span style={{ fontSize: size, lineHeight: 1 }}>{fallback}</span>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── EmojiPicker ───────────────────────────────────────────────────────────────
|
|||
|
|
const SPECIAL_ICONS = [
|
|||
|
|
{ key: "__BTC__", node: <BtcIcon size={24} color="#f59e0b" /> },
|
|||
|
|
{ key: "__ETH__", node: <EthIcon size={18} color="#627eea" /> },
|
|||
|
|
{ key: "__GOLD__", node: <GoldIcon size={28} /> },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
export function EmojiPicker({ selected, T, accentColor, onSelect, onClear }) {
|
|||
|
|
const ac = accentColor || "#f59e0b";
|
|||
|
|
const btnStyle = (active) => ({
|
|||
|
|
width: 34, height: 34, borderRadius: 8,
|
|||
|
|
border: `2px solid ${active ? ac : T.border}`,
|
|||
|
|
background: active ? `${ac}22` : "transparent",
|
|||
|
|
cursor: "pointer", fontSize: 18,
|
|||
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|||
|
|
});
|
|||
|
|
return (
|
|||
|
|
<div style={{
|
|||
|
|
background: T.inputBg, border: `1px solid ${T.border}`,
|
|||
|
|
borderRadius: 12, padding: 10, marginTop: 8,
|
|||
|
|
}}>
|
|||
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, maxHeight: 200, overflowY: "auto" }}>
|
|||
|
|
{selected && (
|
|||
|
|
<button onClick={onClear} style={btnStyle(false)}>
|
|||
|
|
<span style={{ fontSize: 13, color: T.muted }}>✕</span>
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
{SPECIAL_ICONS.map(({ key, node }) => (
|
|||
|
|
<button key={key} onClick={() => onSelect(key)} style={btnStyle(selected === key)}>
|
|||
|
|
{node}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
{EMOJI_OPTIONS.map((e) => (
|
|||
|
|
<button key={e} onClick={() => onSelect(e)} style={btnStyle(selected === e)}>
|
|||
|
|
{e}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── ConfirmDeleteDialog ───────────────────────────────────────────────────────
|
|||
|
|
export function ConfirmDeleteDialog({ naam, T, onCancel, onConfirm }) {
|
|||
|
|
const RED = "#ef4444";
|
|||
|
|
return (
|
|||
|
|
<div style={{
|
|||
|
|
background: "rgba(239,68,68,0.08)",
|
|||
|
|
border: "1px solid rgba(239,68,68,0.25)",
|
|||
|
|
borderRadius: 10, padding: "12px 14px",
|
|||
|
|
}}>
|
|||
|
|
<div style={{ fontSize: 13, color: RED, fontWeight: 600, marginBottom: 10 }}>
|
|||
|
|
Weet je zeker dat je <strong>{naam}</strong> wilt verwijderen?
|
|||
|
|
</div>
|
|||
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|||
|
|
<button onClick={onCancel} style={{
|
|||
|
|
flex: 1, padding: "8px",
|
|||
|
|
background: "transparent",
|
|||
|
|
border: `1px solid ${T.border}`,
|
|||
|
|
borderRadius: 8, color: T.muted,
|
|||
|
|
cursor: "pointer", fontWeight: 600, fontSize: 13,
|
|||
|
|
}}>Annuleren</button>
|
|||
|
|
<button onClick={onConfirm} style={{
|
|||
|
|
flex: 1, padding: "8px",
|
|||
|
|
background: "rgba(239,68,68,0.15)",
|
|||
|
|
border: "1px solid rgba(239,68,68,0.4)",
|
|||
|
|
borderRadius: 8, color: RED,
|
|||
|
|
cursor: "pointer", fontWeight: 700, fontSize: 13,
|
|||
|
|
}}>Ja, verwijderen</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── EmptyState ────────────────────────────────────────────────────────────────
|
|||
|
|
export function EmptyState({ icon, text, hint, onClick, locked, T }) {
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
onClick={!locked ? onClick : undefined}
|
|||
|
|
style={{
|
|||
|
|
textAlign: "center", padding: "36px 0",
|
|||
|
|
color: T.muted,
|
|||
|
|
cursor: locked ? "default" : "pointer",
|
|||
|
|
border: `2px dashed ${T.border}`,
|
|||
|
|
borderRadius: 12,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<div style={{ fontSize: 32, marginBottom: 10 }}>{icon}</div>
|
|||
|
|
<div style={{ fontSize: 13, marginBottom: 4 }}>{text}</div>
|
|||
|
|
{!locked && hint && (
|
|||
|
|
<div style={{ fontSize: 12, color: PURPLE_LIGHT }}>{hint}</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|