Files
VibeFinance/frontend/src/components/ui/index.jsx
T

535 lines
21 KiB
React
Raw Normal View History

import { useRef, useState, useCallback, useContext, createContext } from "react";
import { PURPLE, PURPLE_LIGHT, 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 = 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 { 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.75)",
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: PURPLE_LIGHT }}>{hint}</div>
)}
</div>
);
}