Files
VibeFinance/frontend/src/components/NavBar.jsx

291 lines
14 KiB
React
Raw Normal View History

2026-04-16 10:22:13 +02:00
import { useState, useEffect } from "react";
import { PURPLE, PURPLE_LIGHT, TABS } from "../constants/index.js";
import { version, updateCheckUrl } from "../../package.json";
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 DATA_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 1v8M5 6l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</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>
);
function Tooltip({ label, collapsed }) {
if (!collapsed) return null;
return (
<div style={{
position: "absolute", left: "calc(100% + 10px)", top: "50%",
transform: "translateY(-50%)",
background: "#1e2130", border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 6, padding: "5px 10px",
fontSize: 12, fontWeight: 500, color: "#fff",
whiteSpace: "nowrap", pointerEvents: "none", zIndex: 200,
}}>{label}</div>
);
}
function SidebarItem({ active, onClick, icon, label, T, collapsed }) {
const [hovered, setHovered] = useState(false);
return (
<div
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
title={collapsed ? label : undefined}
style={{
position: "relative",
display: "flex", alignItems: "center",
gap: collapsed ? 0 : 10,
padding: "9px 12px", borderRadius: 8, cursor: "pointer", marginBottom: 6,
justifyContent: collapsed ? "center" : "flex-start",
background: active ? `${PURPLE}18` : "transparent",
color: active ? PURPLE_LIGHT : T.muted,
fontWeight: active ? 600 : 500,
fontSize: 13,
transition: "background 0.15s, color 0.15s",
}}>
<span style={{ flexShrink: 0, display: "flex" }}>{icon}</span>
{!collapsed && <span style={{ flex: 1 }}>{label}</span>}
{hovered && <Tooltip label={label} collapsed={collapsed} />}
</div>
);
}
export default function NavBar() {
const {
tab, setTab,
setShowUsers,
T,
logout,
doBackup, doRestore,
} = useApp();
const [nieuweVersie, setNieuweVersie] = useState(null);
const [dataOpen, setDataOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const W = collapsed ? 56 : 220;
useEffect(() => {
if (!updateCheckUrl) return;
fetch(updateCheckUrl)
.then((r) => r.json())
.then((data) => {
const latest = data?.tag_name?.replace(/^v/, "");
if (latest && latest !== version) setNieuweVersie(latest);
})
.catch(() => {});
}, []);
return (
<div style={{
width: W, minWidth: W, flexShrink: 0,
height: "100vh", position: "sticky", top: 0,
background: T.card, borderRight: `1px solid ${T.border}`,
display: "flex", flexDirection: "column",
zIndex: 100, transition: "width 0.2s ease, min-width 0.2s ease",
overflow: "hidden",
}}>
{/* Logo + collapse knop */}
{!collapsed ? (
<div style={{ padding: "20px 12px 16px", display: "flex", alignItems: "center", gap: 8, justifyContent: "space-between" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, overflow: "hidden" }}>
<div style={{
width: 42, height: 42, flexShrink: 0,
background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
borderRadius: 10, display: "flex", alignItems: "center",
justifyContent: "center", boxShadow: `0 0 12px ${PURPLE}66`,
}}>
<svg width="26" height="26" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#fff"
strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
<line x1="2" y1="17" x2="18" y2="17"
stroke="rgba(255,255,255,0.35)" strokeWidth="1.2" />
</svg>
</div>
<span style={{
fontWeight: 700, fontSize: 17, whiteSpace: "nowrap",
background: `linear-gradient(90deg, ${PURPLE}, #a855f7)`,
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
}}>VibeFinance</span>
</div>
<button onClick={() => setCollapsed(true)} style={{
background: "transparent", border: `1px solid ${T.border}`,
borderRadius: 6, cursor: "pointer", color: T.muted,
width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0, padding: 0,
}}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1" y="1" width="14" height="14" rx="2.5" stroke="currentColor" strokeWidth="1.4"/>
<line x1="5" y1="1.7" x2="5" y2="14.3" stroke="currentColor" strokeWidth="1.4"/>
</svg>
</button>
</div>
) : (
<div style={{ padding: "20px 12px 16px", display: "flex", justifyContent: "center" }}>
<button onClick={() => setCollapsed(false)} style={{
background: "transparent", border: `1px solid ${T.border}`,
borderRadius: 6, cursor: "pointer", color: T.muted,
width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center",
padding: 0,
}}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1" y="1" width="14" height="14" rx="2.5" stroke="currentColor" strokeWidth="1.4"/>
<line x1="5" y1="1.7" x2="5" y2="14.3" stroke="currentColor" strokeWidth="1.4"/>
</svg>
</button>
</div>
)}
{/* Navigatie */}
<nav style={{ flex: 1, padding: `16px ${collapsed ? 8 : 10}px`, overflowY: "auto" }}>
{TABS.map((t, i) => (
<SidebarItem
key={t} active={tab === i} onClick={() => setTab(i)}
icon={TAB_ICONS[i]} label={t} T={T} collapsed={collapsed}
/>
))}
<div style={{ height: 1, background: T.border, margin: "12px 2px" }} />
<SidebarItem
onClick={() => setShowUsers(true)}
icon={GEBRUIKERS_ICON}
label="Gebruikers"
T={T}
collapsed={collapsed}
/>
{/* Data */}
{collapsed ? (
<div style={{ position: "relative" }}>
<SidebarItem
onClick={() => setDataOpen((o) => !o)}
icon={DATA_ICON}
label="Data"
T={T}
collapsed={collapsed}
/>
{dataOpen && (
<div style={{
position: "absolute", left: "calc(100% + 10px)", top: 0,
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 8, padding: "4px 0", minWidth: 160, zIndex: 200,
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
}}>
<div onClick={() => { doBackup(); setDataOpen(false); }} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 14px", cursor: "pointer", color: T.muted, fontSize: 12 }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 1v8M5 6l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><path d="M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
Backup maken
</div>
<div onClick={() => { doRestore(); setDataOpen(false); }} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 14px", cursor: "pointer", color: T.muted, fontSize: 12 }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 106 -6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/><polyline points="2,4 2,8 6,8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
Backup herstellen
</div>
</div>
)}
</div>
) : (
<div>
<div onClick={() => setDataOpen((o) => !o)} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "9px 12px", borderRadius: 8, cursor: "pointer", marginBottom: 6,
color: T.muted, fontSize: 13, fontWeight: 500,
}}>
{DATA_ICON}
<span style={{ flex: 1 }}>Data</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"
style={{ transform: dataOpen ? "rotate(180deg)" : "none", transition: "transform 0.2s", flexShrink: 0 }}>
<polyline points="2,4 6,8 10,4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
{dataOpen && (
<div style={{ paddingLeft: 14, marginBottom: 4 }}>
<div onClick={() => { doBackup(); setDataOpen(false); }} style={{ display: "flex", alignItems: "center", gap: 8, padding: "7px 12px", borderRadius: 8, cursor: "pointer", color: T.muted, fontSize: 12 }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 1v8M5 6l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><path d="M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
Backup maken
</div>
<div onClick={() => { doRestore(); setDataOpen(false); }} style={{ display: "flex", alignItems: "center", gap: 8, padding: "7px 12px", borderRadius: 8, cursor: "pointer", color: T.muted, fontSize: 12 }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 106 -6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/><polyline points="2,4 2,8 6,8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
Backup herstellen
</div>
</div>
)}
</div>
)}
</nav>
{/* Onderste sectie */}
<div style={{ padding: `10px ${collapsed ? 8 : 10}px 24px`, borderTop: `1px solid ${T.border}` }}>
{/* Update melding */}
{nieuweVersie && !collapsed && (
<div style={{
margin: "8px 0", padding: "8px 12px", borderRadius: 8,
background: "rgba(249,115,22,0.1)", border: "1px solid rgba(249,115,22,0.25)",
}}>
<div style={{ fontSize: 11, color: "#f97316", fontWeight: 700, marginBottom: 2 }}>
Nieuwe versie: v{nieuweVersie}
</div>
<div style={{ fontSize: 11, color: T.muted }}>Huidig: v{version}</div>
</div>
)}
{nieuweVersie && collapsed && (
<div style={{ display: "flex", justifyContent: "center", marginBottom: 6 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: "#f97316" }} title={`v${nieuweVersie} beschikbaar`} />
</div>
)}
{/* Uitloggen */}
<SidebarItem
onClick={logout}
icon={<span style={{ color: T.muted, display: "flex" }}>{LOGOUT_ICON}</span>}
label="Log out"
T={T}
collapsed={collapsed}
/>
</div>
</div>
);
}