Files
VibeFinance/frontend/src/components/NavBar.jsx
T
Jordy HoogeveenandClaude Opus 5 3c67381de9 Donut-hover met percentages en werkende update-notificatie
- Donut: naadloze segmenten (donkere spleten weg), hover-tooltip per
  categorie met naam, bedrag en aandeel, middenwaarde schaalt mee
- Update-check via de eigen backend (/api/update-check) i.p.v. direct
  naar de Gitea API, die geen CORS-headers stuurt
- Semver-vergelijking, herhaalde check en release-notes in de melding
- release.ps1 maakt de Gitea-release aan (stap 4)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 09:19:23 +02:00

277 lines
12 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 { useState } from "react";
import { TABS } from "../constants/index.js";
import { version } from "../../package.json";
import { useUpdateCheck } from "../hooks/useUpdateCheck.js";
import { useApp } from "../context/AppContext.jsx";
const TAB_ICONS = [
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1" y="1" width="6" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5"/>
<rect x="9" y="1" width="6" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5"/>
<rect x="1" y="9" width="6" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5"/>
<rect x="9" y="9" width="6" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5"/>
</svg>,
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<polyline points="1,12 5,7 8,10 11,4 15,6" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
<polyline points="11,4 15,4 15,8" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
</svg>,
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1" y="3" width="14" height="10" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<line x1="1" y1="6.5" x2="15" y2="6.5" stroke="currentColor" strokeWidth="1.5"/>
<line x1="3" y1="10" x2="6" y2="10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>,
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="8" width="3" height="6" rx="1" fill="currentColor"/>
<rect x="6.5" y="5" width="3" height="9" rx="1" fill="currentColor"/>
<rect x="11.5" y="2" width="3" height="12" rx="1" fill="currentColor"/>
</svg>,
];
const GEBRUIKERS_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="6" cy="5" r="2.5" stroke="currentColor" strokeWidth="1.5"/>
<path d="M1 13c0-2.5 2-4 5-4s5 1.5 5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="12.5" cy="5" r="2" stroke="currentColor" strokeWidth="1.4"/>
<path d="M14.5 13c0-1.5-.8-2.7-2-3.3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
</svg>
);
const INSTELLINGEN_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M6.5 1a.75.75 0 0 0-.743.648l-.16 1.112a5.07 5.07 0 0 0-.88.51l-1.054-.424a.75.75 0 0 0-.9.31L1.263 4.95a.75.75 0 0 0 .165.96l.888.717a5.14 5.14 0 0 0 0 1.018l-.888.717a.75.75 0 0 0-.165.96l1.5 2.598a.75.75 0 0 0 .9.31l1.053-.424c.276.19.572.36.881.51l.16 1.112A.75.75 0 0 0 6.5 14h3a.75.75 0 0 0 .743-.648l.16-1.112c.309-.15.605-.32.88-.51l1.054.424a.75.75 0 0 0 .9-.31l1.5-2.598a.75.75 0 0 0-.165-.96l-.888-.717a5.14 5.14 0 0 0 0-1.018l.888-.717a.75.75 0 0 0 .165-.96l-1.5-2.598a.75.75 0 0 0-.9-.31l-1.053.424a5.07 5.07 0 0 0-.881-.51L9.743 1.648A.75.75 0 0 0 9.5 1h-3Zm1.5 4.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z"/>
</svg>
);
const LOGOUT_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6 2H3a1 1 0 00-1 1v10a1 1 0 001 1h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<polyline points="10,5 13,8 10,11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="13" y1="8" x2="6" y2="8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
);
const INFO_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5"/>
<line x1="8" y1="7" x2="8" y2="11.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="8" cy="4.5" r="0.8" fill="currentColor"/>
</svg>
);
const MOON_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M13.5 9.5A6 6 0 016.5 2.5a6 6 0 100 11 6 6 0 007-4z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
function initials(naam = "") {
const parts = naam.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return naam.slice(0, 2).toUpperCase();
}
function SectionLabel({ children }) {
return (
<div style={{
fontSize: 11, fontWeight: 700, letterSpacing: "0.12em",
textTransform: "uppercase", color: "#3d4966",
padding: "0 12px", marginBottom: 2, marginTop: 20,
}}>
{children}
</div>
);
}
const ACTIVE_BG = "#7c3aed";
const ACTIVE_SHADOW = "0 4px 14px rgba(124,58,237,0.4)";
function NavItem({ active, onClick, icon, label, T, danger }) {
const [hovered, setHovered] = useState(false);
const isLit = active || hovered;
return (
<div
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "flex", alignItems: "center", gap: 10,
padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4,
background: active
? ACTIVE_BG
: hovered ? "rgba(139,92,246,0.08)" : "transparent",
color: active ? "#fff" : hovered ? T.text : (danger ? "#ef4444" : T.muted),
fontWeight: active ? 600 : 400,
fontSize: 15,
boxShadow: active ? ACTIVE_SHADOW : "none",
transition: "all 0.15s",
}}
>
<span style={{ flexShrink: 0, display: "flex", color: "inherit", opacity: active ? 1 : (isLit ? 0.85 : 0.55) }}>{icon}</span>
<span style={{ flex: 1 }}>{label}</span>
</div>
);
}
function OverPopup({ onClose, nieuweVersie, T }) {
return (
<>
{/* Overlay */}
<div onClick={onClose} style={{
position: "fixed", inset: 0, zIndex: 490,
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
{/* Modal */}
<div onClick={(e) => e.stopPropagation()} style={{
background: T.popupBg,
border: `1px solid ${T.border}`,
borderRadius: 16, padding: "28px 28px 24px", width: 300,
boxShadow: "0 24px 60px rgba(0,0,0,0.5)",
position: "relative",
}}>
{/* Sluitknop */}
<button onClick={onClose} style={{
position: "absolute", top: 14, right: 14,
background: "transparent", border: "none", cursor: "pointer",
color: T.muted, fontSize: 20, lineHeight: 1, padding: 4,
}}>×</button>
{/* Logo + naam */}
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: "rgba(124,58,237,0.15)", border: "1px solid rgba(124,58,237,0.3)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="22" height="22" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#a855f7" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
<line x1="2" y1="17" x2="18" y2="17" stroke="rgba(168,85,247,0.35)" strokeWidth="1.4"/>
</svg>
</div>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: T.text }}>VibeFinance</div>
</div>
</div>
<div style={{ height: 1, background: T.border, marginBottom: 16 }} />
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span style={{ fontSize: 13, color: T.muted }}>Versie</span>
<span style={{ fontSize: 12, fontWeight: 700, color: "#a855f7", background: "rgba(124,58,237,0.15)", borderRadius: 6, padding: "3px 10px" }}>
v{version}
</span>
</div>
{nieuweVersie && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span style={{ fontSize: 13, color: T.muted }}>Nieuw beschikbaar</span>
<span style={{ fontSize: 12, fontWeight: 700, color: "#f97316", background: "rgba(249,115,22,0.12)", borderRadius: 6, padding: "3px 10px" }}>
v{nieuweVersie.latest}
</span>
</div>
)}
<div style={{ height: 1, background: T.border, margin: "12px 0" }} />
<div style={{ fontSize: 12, color: T.muted, textAlign: "center" }}>© {new Date().getFullYear()} VibeFinance</div>
</div>
</div>
</>
);
}
function DarkModeToggle({ darkMode, setDarkMode, T }) {
return (
<div
onClick={() => setDarkMode(!darkMode)}
style={{
display: "flex", alignItems: "center", gap: 10,
padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4,
color: T.muted, fontSize: 13.5, fontWeight: 500,
transition: "background 0.12s",
}}
onMouseEnter={e => e.currentTarget.style.background = "rgba(255,255,255,0.04)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>
<span style={{ flexShrink: 0, display: "flex", opacity: 0.6 }}>{MOON_ICON}</span>
<span style={{ flex: 1 }}>Dark Mode</span>
{/* Toggle pill */}
<div style={{
width: 36, height: 20, borderRadius: 99, flexShrink: 0,
background: darkMode ? "#7c3aed" : "rgba(255,255,255,0.15)",
position: "relative", transition: "background 0.2s",
}}>
<div style={{
position: "absolute", top: 3, left: darkMode ? 19 : 3,
width: 14, height: 14, borderRadius: "50%",
background: "#fff", transition: "left 0.2s",
boxShadow: "0 1px 4px rgba(0,0,0,0.3)",
}} />
</div>
</div>
);
}
export default function NavBar() {
const { tab, setTab, setShowUsers, setShowInstellingen, T, logout, darkMode, setDarkMode, currentUser, avatar } = useApp();
const nieuweVersie = useUpdateCheck();
const [overOpen, setOverOpen] = useState(false);
return (
<div style={{
width: 260, minWidth: 260, flexShrink: 0,
height: "100vh", position: "sticky", top: 0,
background: T.sidebar ?? T.card,
borderRight: `1px solid ${T.border}`,
display: "flex", flexDirection: "column",
zIndex: 100,
}}>
{/* Logo */}
<div style={{ padding: "22px 16px 28px", display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 32, height: 32, flexShrink: 0, borderRadius: 9,
background: "rgba(124,58,237,0.15)",
border: "1px solid rgba(124,58,237,0.3)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8"
stroke="#a855f7" strokeWidth="2.2"
strokeLinejoin="round" strokeLinecap="round"/>
<line x1="2" y1="17" x2="18" y2="17"
stroke="rgba(168,85,247,0.35)" strokeWidth="1.4"/>
</svg>
</div>
<span style={{ fontSize: 17, fontWeight: 700, color: T.text, letterSpacing: "-0.3px" }}>VibeFinance</span>
</div>
{/* Nav */}
<nav style={{ flex: 1, padding: "0 8px", overflowY: "auto" }}>
{TABS.map((t, i) => (
<NavItem key={t} active={tab === i} onClick={() => setTab(i)} icon={TAB_ICONS[i]} label={t} T={T} />
))}
<NavItem onClick={() => setShowUsers(true)} icon={GEBRUIKERS_ICON} label="Gebruikers" T={T} />
<NavItem onClick={() => setShowInstellingen(true)} icon={INSTELLINGEN_ICON} label="Instellingen" T={T} />
<div style={{ position: "relative" }}>
<NavItem onClick={() => setOverOpen(o => !o)} icon={INFO_ICON} label="Over VibeFinance" active={false} T={T} />
{overOpen && <OverPopup onClose={() => setOverOpen(false)} nieuweVersie={nieuweVersie} T={T} />}
{nieuweVersie && !overOpen && (
<div style={{ position: "absolute", top: 10, right: 26, width: 6, height: 6, borderRadius: "50%", background: "#f97316", pointerEvents: "none" }} />
)}
</div>
</nav>
{/* Uitloggen */}
<div style={{ padding: "8px 8px 12px", borderTop: `1px solid ${T.border}` }}>
<NavItem onClick={logout} icon={LOGOUT_ICON} label="Log Out" T={T} />
</div>
</div>
);
}