Files
VibeFinance/frontend/src/components/ui/index.jsx
T
2026-10-01 16:57:16 +02:00

540 lines
21 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState, useCallback, useContext, createContext } from "react";
import { EMOJI_OPTIONS } from "../../constants/index.js";
import { useApp } from "../../context/AppContext.jsx";
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 }) {
const { T } = useApp();
const kleur = color ?? T.accentTekst;
return (
<span style={{
background: `${kleur}22`, color: kleur,
border: `1px solid ${kleur}44`,
borderRadius: 99, padding: "2px 10px",
fontSize: 11, fontWeight: 700,
}}>
{label}
</span>
);
}
// ── TabBtn ────────────────────────────────────────────────────────────────────
export function TabBtn({ active, onClick, children }) {
const { T } = useApp();
return (
<button
onClick={onClick}
style={{
padding: "8px 16px", border: "none", borderRadius: 8, cursor: "pointer",
fontSize: 13, fontWeight: active ? 700 : 500,
background: active ? `${T.accent}22` : "transparent",
color: active ? T.accentTekst : T.muted,
borderBottom: active ? `2px solid ${T.accentTekst}` : "2px solid transparent",
transition: "all 0.15s",
}}
>
{children}
</button>
);
}
// ── VProgressBar ──────────────────────────────────────────────────────────────
export function VProgressBar({ value, max, color }) {
const { T } = useApp();
const kleur = color ?? T.accent;
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: kleur,
borderRadius: 99, transition: "width 0.5s ease",
}} />
</div>
);
}
// ── DonutChart ────────────────────────────────────────────────────────────────
export function DonutChart({ segments, total, size = 120, label, centerVal }) {
const { darkMode, T } = useApp();
const wrapRef = useRef(null);
const [hover, setHover] = useState(null); // { i, x, y } — actieve categorie + cursor
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 multi = active.length > 1;
// Middenwaarde schaalt mee met de beschikbare ruimte binnen de ring, zodat
// lange bedragen niet uit verhouding raken.
const innerW = (2 * r - strokeW) * 0.92;
const valLen = centerVal ? String(centerVal).length : 0;
const valFont = valLen
? Math.min(size * 0.125, innerW / (valLen * 0.6))
: 0;
// Segmenten sluiten naadloos aan (geen tussenruimte).
let off = 0;
const slices = active.map((s) => {
const raw = (s.val / total) * circ;
const sl = { ...s, dash: Math.max(raw, 1), offset: off, share: total ? s.val / total : 0 };
off += raw;
return sl;
});
const track = (i) => (e) => {
const box = wrapRef.current?.getBoundingClientRect();
setHover({
i,
x: box ? e.clientX - box.left : cx,
y: box ? e.clientY - box.top : cy,
});
};
const hovered = hover ? slices[hover.i] : null;
const fmtPct = (v) => `${(v * 100).toFixed(1).replace(".", ",")}%`;
const fmtEur = (v) =>
new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v || 0);
return (
<div ref={wrapRef} style={{ position: "relative", width: size, height: size, lineHeight: 0 }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{/* Baan — zelfde toon als de voortgangsbalken */}
<circle cx={cx} cy={cy} r={r} fill="none"
stroke={darkMode ? "rgba(255,255,255,0.07)" : "rgba(15,23,42,0.07)"}
strokeWidth={strokeW} />
{/* Segmenten */}
{slices.map((s, i) => (
<circle key={`c${i}`} cx={cx} cy={cy} r={r} fill="none"
stroke={s.color} strokeWidth={hover && hover.i === i ? strokeW * 1.1 : strokeW}
strokeDasharray={`${s.dash} ${circ - s.dash}`}
strokeDashoffset={-s.offset + circ / 4}
strokeLinecap={multi ? "butt" : "round"}
opacity={hover && hover.i !== i ? 0.35 : 1}
style={{ cursor: "pointer", transition: "opacity 0.15s ease, stroke-width 0.15s ease" }}
onMouseEnter={track(i)}
onMouseMove={track(i)}
onMouseLeave={() => setHover(null)}
/>
))}
{/* Center label */}
{label && (
<text x={cx} y={cy - valFont * 0.62} fill={T.muted}
fontSize={size * 0.085} textAnchor="middle" dominantBaseline="middle"
fontFamily="inherit" style={{ pointerEvents: "none" }}>
{label}
</text>
)}
{/* Center value */}
{centerVal && (
<text x={cx} y={label ? cy + valFont * 0.45 : cy} fill={T.text}
fontSize={valFont} fontWeight="800" textAnchor="middle" dominantBaseline="middle"
fontFamily="inherit" style={{ pointerEvents: "none" }}>
{centerVal}
</text>
)}
</svg>
{/* Tooltip: categorie, bedrag en aandeel */}
{hovered && (
<div style={{
position: "absolute", left: hover.x, top: hover.y - 12,
transform: "translate(-50%, -100%)", pointerEvents: "none",
background: T.popupBg, border: `1px solid ${T.border}`,
borderRadius: 10, padding: "6px 10px", boxShadow: T.shadow,
whiteSpace: "nowrap", lineHeight: 1.35, zIndex: 5,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: hovered.color }} />
<span style={{ fontSize: 11, fontWeight: 700, color: T.text }}>
{hovered.naam || hovered.label || "—"}
</span>
</div>
<div style={{ fontSize: 11, color: T.subtext, marginTop: 2 }}>
{fmtEur(hovered.val)} · <span style={{ fontWeight: 700, color: T.text }}>{fmtPct(hovered.share)}</span>
</div>
</div>
)}
</div>
);
}
// ── 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.45)",
zIndex,
}}
>
{children}
</div>
);
}
export function ModalBox({ children, maxWidth = 480 }) {
const { T } = useApp();
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: T.popupBg,
border: `1px solid ${T.border}`,
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: T.accentTekst }}>{hint}</div>
)}
</div>
);
}