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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e45d5b73fb
commit
3c67381de9
@@ -184,11 +184,48 @@ docker login vibehoogie.duckdns.org:3000
|
||||
|
||||
## Update-notificaties
|
||||
|
||||
De app vergelijkt de draaiende versie met de laatste release op Gitea. Configuratie staat in `package.json`:
|
||||
De app vergelijkt de draaiende versie met de laatste **release** op Gitea en toont
|
||||
dat op het belletje in de topbalk en onder "Over VibeFinance".
|
||||
|
||||
**Hoe de check loopt**
|
||||
|
||||
1. De frontend vraagt `/api/update-check` op de eigen backend — de Gitea API stuurt
|
||||
geen CORS-headers, dus de browser mag die niet direct bevragen.
|
||||
2. De backend haalt `releases/latest` bij Gitea op en cachet het antwoord 15 minuten.
|
||||
3. De frontend kijkt bij het openen, daarna elk half uur en bij het terugkeren naar
|
||||
het tabblad. Alleen een écht hoger versienummer geeft een melding.
|
||||
|
||||
Builds met een `-dev` suffix melden nooit (die lopen voor op de laatste release).
|
||||
Wil je de melding tijdens ontwikkeling toch testen: open de app met `?updatecheck`
|
||||
in de URL.
|
||||
|
||||
**Configuratie**
|
||||
|
||||
De bron staat in de root `package.json`:
|
||||
|
||||
```json
|
||||
"repository": "https://vibehoogie.duckdns.org:3000/vibe/VibeFinance",
|
||||
"updateCheckUrl": "https://vibehoogie.duckdns.org:3000/api/v1/repos/vibe/VibeFinance/releases/latest"
|
||||
"repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance",
|
||||
"updateCheckUrl": "https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest"
|
||||
```
|
||||
|
||||
Vite en de build-scripts lezen deze velden automatisch uit — geen handmatige build-args nodig.
|
||||
`dev.ps1` zet die URL als `UPDATE_CHECK_URL` in de omgeving; in productie komt hij
|
||||
uit `.env` (of de default in `docker-compose.yml`). Beschikbare backend-variabelen:
|
||||
|
||||
| Variabele | Betekenis |
|
||||
|---|---|
|
||||
| `UPDATE_CHECK_URL` | Release-API die gepolld wordt. Leeg = melding uit. |
|
||||
| `UPDATE_CHECK_TOKEN` | Alleen nodig als de repo in Gitea niet publiek leesbaar is. |
|
||||
|
||||
**Een release publiceren**
|
||||
|
||||
Een kale Git-tag is niet genoeg: `releases/latest` geeft alleen echte releases terug.
|
||||
`release.ps1` maakt die aan (stap 4) met de changelog-sectie van die versie als
|
||||
release-notes. Daarvoor is een Gitea-token met release-rechten nodig:
|
||||
|
||||
```powershell
|
||||
$env:GITEA_TOKEN = "<token>"
|
||||
.\release.ps1
|
||||
```
|
||||
|
||||
Zonder token slaat het script die stap over met een waarschuwing — images en tag
|
||||
staan dan wel klaar, maar gebruikers zien geen melding tot de release bestaat.
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.5] - 2026-09-04
|
||||
|
||||
### Toegevoegd
|
||||
- Donut-grafieken: hover per categorie toont naam, bedrag en percentage
|
||||
|
||||
### Gewijzigd
|
||||
- Update-check loopt nu via de eigen backend (`/api/update-check`) in plaats van
|
||||
rechtstreeks naar de Gitea API — de browser blokkeerde die call (geen CORS-headers)
|
||||
- Versievergelijking is semver-based: alleen een hoger versienummer meldt, `-dev`
|
||||
builds melden niets meer
|
||||
- Update-check herhaalt zich elk half uur en bij terugkeer naar het tabblad
|
||||
(was: eenmalig bij laden)
|
||||
- `release.ps1` maakt de Gitea-release aan met de changelog-sectie als release-notes
|
||||
(een kale Git-tag komt niet in `releases/latest` en gaf dus geen melding)
|
||||
- Middenwaarde in de donut schaalt mee met de beschikbare ruimte
|
||||
|
||||
### Opgelost
|
||||
- Donkere spleten tussen de donut-segmenten (de tussenruimte liet de achtergrondbaan zien)
|
||||
- De update-melding op het belletje bleef altijd leeg door de geblokkeerde API-call
|
||||
|
||||
---
|
||||
|
||||
## [0.1.3] - 2026-04-16
|
||||
|
||||
### Toegevoegd
|
||||
|
||||
@@ -8,4 +8,11 @@ export const config = {
|
||||
JWT_EXPIRES: process.env.JWT_EXPIRES || "7d",
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
REDIS_URL: process.env.REDIS_URL,
|
||||
|
||||
// Update-notificaties: welke release-API wordt gepolld (Gitea).
|
||||
// Leeg laten schakelt de melding in de app uit.
|
||||
UPDATE_CHECK_URL: process.env.UPDATE_CHECK_URL
|
||||
?? "https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest",
|
||||
// Alleen nodig wanneer de repo in Gitea niet publiek leesbaar is.
|
||||
UPDATE_CHECK_TOKEN: process.env.UPDATE_CHECK_TOKEN || null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Router } from "express";
|
||||
import { config } from "../config/index.js";
|
||||
import { logger } from "../logger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Gitea wordt server-side bevraagd: de browser mag dat niet (geen CORS-headers
|
||||
// op de Gitea API) en zo blijft het antwoord gecached voor alle gebruikers.
|
||||
const TTL_MS = 15 * 60 * 1000;
|
||||
const TIMEOUT_MS = 5000;
|
||||
let cache = { at: 0, payload: null };
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
if (!config.UPDATE_CHECK_URL) {
|
||||
return res.json({ latest: null, disabled: true });
|
||||
}
|
||||
if (cache.payload && Date.now() - cache.at < TTL_MS) {
|
||||
return res.json(cache.payload);
|
||||
}
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const upstream = await fetch(config.UPDATE_CHECK_URL, {
|
||||
signal: ctrl.signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(config.UPDATE_CHECK_TOKEN && { Authorization: `token ${config.UPDATE_CHECK_TOKEN}` }),
|
||||
},
|
||||
});
|
||||
if (!upstream.ok) throw new Error(`upstream ${upstream.status}`);
|
||||
const rel = await upstream.json();
|
||||
|
||||
const payload = {
|
||||
latest: String(rel?.tag_name ?? "").replace(/^v/, "") || null,
|
||||
naam: rel?.name || null,
|
||||
notes: rel?.body || null,
|
||||
url: rel?.html_url || null,
|
||||
publishedAt: rel?.published_at || null,
|
||||
};
|
||||
cache = { at: Date.now(), payload };
|
||||
res.json(payload);
|
||||
} catch (err) {
|
||||
logger.warn({ err: err.message }, "Update-check bij Gitea mislukt");
|
||||
// Val terug op het laatst bekende antwoord; anders "geen update bekend".
|
||||
res.json(cache.payload ?? { latest: null, error: true });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -10,6 +10,7 @@ import authRouter from "./routes/auth.js";
|
||||
import dataRouter from "./routes/data.js";
|
||||
import usersRouter from "./routes/users.js";
|
||||
import healthRouter from "./routes/health.js";
|
||||
import updateRouter from "./routes/update.js";
|
||||
import { errorHandler } from "./middleware/errorHandler.js";
|
||||
|
||||
export const logger = pino({
|
||||
@@ -41,6 +42,7 @@ if (config.NODE_ENV !== "test") {
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
app.use("/api/health", healthRouter);
|
||||
app.use("/api/update-check", updateRouter);
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/data", dataRouter);
|
||||
app.use("/api/users", usersRouter);
|
||||
|
||||
@@ -54,15 +54,15 @@ if (-not $appVersion) {
|
||||
|
||||
$env:VERSION = $appVersion
|
||||
$env:VITE_APP_VERSION = $appVersion
|
||||
# De backend doet de update-check (server-side, geen CORS) — URL doorgeven aan compose
|
||||
$env:UPDATE_CHECK_URL = $updateCheckUrl
|
||||
if (-not $env:POSTGRES_PASSWORD) { $env:POSTGRES_PASSWORD = "devpassword" }
|
||||
|
||||
# Versie synchroniseren naar frontend/package.json
|
||||
$frontendPkgPath = Join-Path $PSScriptRoot "frontend/package.json"
|
||||
$frontendPkg = Get-Content $frontendPkgPath -Raw | ConvertFrom-Json
|
||||
$updateUrl = if ($rootPkg.updateCheckUrl) { $rootPkg.updateCheckUrl } else { "" }
|
||||
if ($frontendPkg.version -ne $appVersion -or $frontendPkg.updateCheckUrl -ne $updateUrl) {
|
||||
if ($frontendPkg.version -ne $appVersion) {
|
||||
$frontendPkg.version = $appVersion
|
||||
$frontendPkg.updateCheckUrl = $updateUrl
|
||||
[System.IO.File]::WriteAllText($frontendPkgPath, ($frontendPkg | ConvertTo-Json -Depth 10))
|
||||
Write-Host "Versie gesynchroniseerd naar frontend: $appVersion" -ForegroundColor Green
|
||||
}
|
||||
@@ -113,7 +113,6 @@ function Invoke-FrontendPush {
|
||||
--build-arg APP_VERSION=$appVersion `
|
||||
--build-arg VITE_API_URL=$apiUrl `
|
||||
--build-arg VITE_REPO_URL=$repoUrl `
|
||||
--build-arg VITE_UPDATE_CHECK_URL=$updateCheckUrl `
|
||||
(Join-Path $PSScriptRoot "frontend")
|
||||
if ($LASTEXITCODE -ne 0) { throw "Frontend build mislukt" }
|
||||
Write-Host "Gepusht: $frontendVersionDev" -ForegroundColor Green
|
||||
|
||||
@@ -67,6 +67,7 @@ services:
|
||||
JWT_SECRET: dev-secret-change-in-production
|
||||
JWT_EXPIRES: 7d
|
||||
CORS_ORIGIN: http://localhost
|
||||
UPDATE_CHECK_URL: ${UPDATE_CHECK_URL:-https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-vibefinance}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-vibefinance}
|
||||
networks:
|
||||
- dev
|
||||
|
||||
@@ -34,6 +34,8 @@ services:
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-vibefinance}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-vibefinance}
|
||||
# Bron voor de update-melding in de app (leeg = melding uit)
|
||||
UPDATE_CHECK_URL: ${UPDATE_CHECK_URL:-https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/health | grep -q status || exit 1"]
|
||||
interval: 10s
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "vibefinance-frontend",
|
||||
"version": "0.1.5-dev",
|
||||
"updateCheckUrl": "https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0eddd4"/>
|
||||
<stop offset="100%" stop-color="#0891b2"/>
|
||||
<stop offset="0%" stop-color="#6d28d9"/>
|
||||
<stop offset="100%" stop-color="#a855f7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="8" fill="url(#bg)"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
+21
-15
@@ -10,7 +10,8 @@ import SchuldTab from "./pages/SchuldTab.jsx";
|
||||
import VoortgangTab from "./pages/VoortgangTab.jsx";
|
||||
import { RED, PURPLE_LIGHT } from "./constants/index.js";
|
||||
import { fmt } from "./utils/format.js";
|
||||
import { version, updateCheckUrl } from "../package.json";
|
||||
import { version } from "../package.json";
|
||||
import { useUpdateCheck } from "./hooks/useUpdateCheck.js";
|
||||
import ProfielPopup from "./components/ProfielPopup.jsx";
|
||||
import InstellingenModal from "./components/InstellingenModal.jsx";
|
||||
|
||||
@@ -26,21 +27,10 @@ function AppInner() {
|
||||
const { loggedIn, login, logout, locked, tab, showUsers, setShowUsers, showInstellingen, setShowInstellingen, loading, T, currentUser, avatar, idleMinutes, totEV } = useApp();
|
||||
const [profielOpen, setProfielOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [nieuweVersie, setNieuweVersie] = useState(null);
|
||||
const nieuweVersie = useUpdateCheck(); // { latest, naam, notes, url } of null
|
||||
const [bellOpen, setBellOpen] = useState(false);
|
||||
const searchRef = useRef(null);
|
||||
|
||||
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(() => {});
|
||||
}, []);
|
||||
|
||||
const idleTimer = useRef(null);
|
||||
const IDLE_MS = (idleMinutes ?? 30) * 60 * 1000;
|
||||
const [sessionVerlopen, setSessionVerlopen] = useState(false);
|
||||
@@ -190,7 +180,7 @@ function AppInner() {
|
||||
<div style={{
|
||||
position: "absolute", right: 0, top: "calc(100% + 10px)",
|
||||
zIndex: 200, width: 280,
|
||||
background: T.card, border: `1px solid ${T.border}`,
|
||||
background: T.popupBg, border: `1px solid ${T.border}`,
|
||||
borderRadius: 14, padding: "16px 18px",
|
||||
boxShadow: T.shadow,
|
||||
}}>
|
||||
@@ -217,8 +207,24 @@ function AppInner() {
|
||||
Nieuwe versie beschikbaar
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}>
|
||||
v{nieuweVersie} is uitgebracht. Je gebruikt momenteel v{version}.
|
||||
v{nieuweVersie.latest} is uitgebracht. Je gebruikt momenteel v{version}.
|
||||
</div>
|
||||
{nieuweVersie.notes && (
|
||||
<div style={{
|
||||
fontSize: 11, color: T.subtext, marginTop: 6, lineHeight: 1.5,
|
||||
maxHeight: 130, overflowY: "auto", whiteSpace: "pre-wrap",
|
||||
}}>
|
||||
{nieuweVersie.notes}
|
||||
</div>
|
||||
)}
|
||||
{nieuweVersie.url && (
|
||||
<a href={nieuweVersie.url} target="_blank" rel="noreferrer" style={{
|
||||
display: "inline-block", marginTop: 8,
|
||||
fontSize: 11, fontWeight: 700, color: "#f97316", textDecoration: "none",
|
||||
}}>
|
||||
Release bekijken →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function GebruikersBeheer({ onClose }) {
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: T.card, border: `1px solid ${T.border}`,
|
||||
background: T.popupBg, border: `1px solid ${T.border}`,
|
||||
borderRadius: 16, width: "100%", maxWidth: 700,
|
||||
maxHeight: "90vh", display: "flex", flexDirection: "column",
|
||||
boxShadow: "0 16px 48px rgba(0,0,0,0.4)",
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function InstellingenModal({ onClose }) {
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: T.card, border: `1px solid ${T.border}`,
|
||||
background: T.popupBg, border: `1px solid ${T.border}`,
|
||||
borderRadius: 16, padding: "22px 22px 8px",
|
||||
width: 360, boxShadow: "0 16px 48px rgba(0,0,0,0.4)",
|
||||
color: T.text,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { RED } from "../constants/index.js";
|
||||
import LoginBackground from "./LoginBackground.jsx";
|
||||
|
||||
const MailIcon = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
|
||||
@@ -43,16 +44,17 @@ function FloatingInput({ label, type = "text", value, onChange, onKeyDown, icon,
|
||||
border: `1px solid ${focused ? "rgba(124,58,237,0.7)" : "rgba(255,255,255,0.1)"}`,
|
||||
borderRadius: 10, background: "rgba(255,255,255,0.03)",
|
||||
transition: "border-color 0.15s",
|
||||
padding: "8px 14px 4px",
|
||||
}}>
|
||||
<label style={{
|
||||
position: "absolute", top: -8, left: 12,
|
||||
display: "block",
|
||||
fontSize: 10, fontWeight: 700,
|
||||
color: focused ? "#a855f7" : "rgba(255,255,255,0.35)",
|
||||
background: "#1a1b22", padding: "0 5px",
|
||||
letterSpacing: "0.06em", textTransform: "uppercase",
|
||||
pointerEvents: "none", transition: "color 0.15s",
|
||||
marginBottom: 4,
|
||||
}}>{label}</label>
|
||||
<div style={{ display: "flex", alignItems: "center", padding: "13px 14px", gap: 10 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
{icon && (
|
||||
<span style={{ color: focused ? "rgba(168,85,247,0.7)" : "rgba(255,255,255,0.25)", flexShrink: 0, display: "flex", transition: "color 0.15s" }}>
|
||||
{icon}
|
||||
@@ -74,27 +76,6 @@ function FloatingInput({ label, type = "text", value, onChange, onKeyDown, icon,
|
||||
);
|
||||
}
|
||||
|
||||
function MiniBarChart() {
|
||||
const bars = [38, 52, 45, 68, 74, 61, 89];
|
||||
const months = ["nov", "dec", "jan", "feb", "mrt", "apr", "mei"];
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, height: 48, marginTop: 4 }}>
|
||||
{bars.map((h, i) => (
|
||||
<div key={i} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}>
|
||||
<div style={{
|
||||
width: "100%", borderRadius: 4,
|
||||
height: `${h}%`,
|
||||
background: i === bars.length - 1
|
||||
? "linear-gradient(180deg, #a855f7, #7c3aed)"
|
||||
: "rgba(255,255,255,0.12)",
|
||||
}} />
|
||||
<span style={{ fontSize: 8, color: "rgba(255,255,255,0.35)", whiteSpace: "nowrap" }}>{months[i]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage({ onLogin }) {
|
||||
const [mode, setMode] = useState("login");
|
||||
const [naam, setNaam] = useState("");
|
||||
@@ -153,7 +134,97 @@ export default function LoginPage({ onLogin }) {
|
||||
boxShadow: "0 40px 100px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.06)",
|
||||
}}>
|
||||
|
||||
{/* LEFT: Form panel */}
|
||||
{/* LEFT: Merkeigen achtergrond + floating light app-screens */}
|
||||
<div style={{
|
||||
flex: 1, position: "relative", overflow: "hidden",
|
||||
background: "#1a0b2e",
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<LoginBackground />
|
||||
|
||||
{/* Logo top-left */}
|
||||
<div style={{ position: "absolute", top: 32, left: 32, display: "flex", alignItems: "center", gap: 10, zIndex: 3 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, flexShrink: 0, borderRadius: 10,
|
||||
background: "rgba(124,58,237,0.25)", border: "1px solid rgba(168,85,247,0.5)",
|
||||
backdropFilter: "blur(6px)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#c4b5fd" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
|
||||
<line x1="2" y1="17" x2="18" y2="17" stroke="rgba(196,181,253,0.4)" strokeWidth="1.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span style={{ fontSize: 19, fontWeight: 700, color: "#fff", letterSpacing: "-0.3px", textShadow: "0 2px 8px rgba(0,0,0,0.4)" }}>VibeFinance</span>
|
||||
</div>
|
||||
|
||||
{/* Floating light "app screen" card */}
|
||||
<div style={{
|
||||
position: "absolute", left: 36, bottom: 84, zIndex: 3,
|
||||
width: 250,
|
||||
background: "rgba(26,16,46,0.55)", backdropFilter: "blur(16px)",
|
||||
border: "1px solid rgba(255,255,255,0.14)",
|
||||
borderRadius: 16, padding: "16px 18px",
|
||||
boxShadow: "0 16px 40px rgba(0,0,0,0.45)",
|
||||
}}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#fff" }}>Vermogensoverzicht</div>
|
||||
<div style={{ fontSize: 9, color: "rgba(255,255,255,0.55)", marginTop: 1 }}>The Road naar € 500.000</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 9, color: "#c4b5fd", background: "rgba(168,85,247,0.22)", borderRadius: 6, padding: "3px 7px", fontWeight: 600 }}>Mei 2026</div>
|
||||
</div>
|
||||
{[
|
||||
{ label: "Eigen Vermogen", val: "€ 445.500", color: "#34d399", icon: "↑" },
|
||||
{ label: "Schuld", val: "€ 54.500", color: "#fb923c", icon: "↓" },
|
||||
{ label: "Winst", val: "€ 142.500", color: "#c084fc", icon: "↑" },
|
||||
].map(({ label, val, color, icon }) => (
|
||||
<div key={label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
||||
<div style={{ width: 22, height: 22, borderRadius: 6, background: `${color}33`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, color, fontWeight: 700 }}>{icon}</div>
|
||||
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.7)" }}>{label}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: "#fff" }}>{val}</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.55)" }}>Voortgang naar doel</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: "#c4b5fd" }}>89%</span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: "rgba(255,255,255,0.15)", borderRadius: 99, overflow: "hidden" }}>
|
||||
<div style={{ width: "89%", height: "100%", background: "linear-gradient(90deg, #7c3aed, #a855f7)", borderRadius: 99 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating PNL badge — overlapt het screen, zoals het voorbeeld */}
|
||||
<div style={{
|
||||
position: "absolute", left: 250, bottom: 200, zIndex: 4,
|
||||
background: "rgba(26,16,46,0.55)", backdropFilter: "blur(16px)",
|
||||
border: "1px solid rgba(255,255,255,0.14)",
|
||||
borderRadius: 14, padding: "10px 14px",
|
||||
boxShadow: "0 12px 32px rgba(0,0,0,0.4)",
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
}}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: "50%", background: "linear-gradient(135deg, #7c3aed, #a855f7)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none">
|
||||
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#fff" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: "rgba(255,255,255,0.6)", fontWeight: 600 }}>ROI</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 800, color: "#34d399" }}>+47,03%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom tagline */}
|
||||
<div style={{ position: "absolute", bottom: 28, left: 36, right: 36, fontSize: 12, color: "rgba(255,255,255,0.7)", lineHeight: 1.6, zIndex: 3, textShadow: "0 1px 6px rgba(0,0,0,0.5)" }}>
|
||||
Beheer je vermogen, volg je groei en bereik je financiële doelen — allemaal op één plek.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Form panel */}
|
||||
<div style={{
|
||||
width: 460, flexShrink: 0,
|
||||
background: "#1a1b22",
|
||||
@@ -265,113 +336,6 @@ export default function LoginPage({ onLogin }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Purple mockup panel */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
background: "linear-gradient(145deg, #3b0764 0%, #5b21b6 40%, #7c3aed 80%, #9333ea 100%)",
|
||||
position: "relative", overflow: "hidden",
|
||||
display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
padding: "48px 40px",
|
||||
}}>
|
||||
{/* Decorative circles */}
|
||||
<div style={{ position: "absolute", width: 320, height: 320, borderRadius: "50%", background: "rgba(168,85,247,0.2)", top: -100, right: -80, pointerEvents: "none" }} />
|
||||
<div style={{ position: "absolute", width: 220, height: 220, borderRadius: "50%", background: "rgba(168,85,247,0.15)", bottom: -40, left: -60, pointerEvents: "none" }} />
|
||||
<div style={{ position: "absolute", width: 120, height: 120, borderRadius: "50%", background: "rgba(168,85,247,0.12)", top: "42%", left: "8%", pointerEvents: "none" }} />
|
||||
<div style={{ position: "absolute", width: 60, height: 60, borderRadius: "50%", background: "rgba(255,255,255,0.06)", top: "18%", right: "22%", pointerEvents: "none" }} />
|
||||
|
||||
{/* Top badge */}
|
||||
<div style={{
|
||||
background: "rgba(255,255,255,0.13)", backdropFilter: "blur(8px)",
|
||||
borderRadius: 99, padding: "7px 16px", fontSize: 12, fontWeight: 600,
|
||||
color: "rgba(255,255,255,0.92)", marginBottom: 32,
|
||||
border: "1px solid rgba(255,255,255,0.18)",
|
||||
display: "flex", alignItems: "center", gap: 7,
|
||||
position: "relative", zIndex: 2,
|
||||
}}>
|
||||
<span style={{ color: "#fbbf24", fontSize: 14 }}>✦</span>
|
||||
Jouw vermogen in één oogopslag
|
||||
</div>
|
||||
|
||||
{/* Main glassmorphism card */}
|
||||
<div style={{
|
||||
background: "rgba(255,255,255,0.11)", backdropFilter: "blur(16px)",
|
||||
border: "1px solid rgba(255,255,255,0.18)",
|
||||
borderRadius: 18, padding: "22px 24px",
|
||||
width: "100%", maxWidth: 320,
|
||||
boxShadow: "0 12px 40px rgba(0,0,0,0.3)",
|
||||
position: "relative", zIndex: 2, marginBottom: 16,
|
||||
}}>
|
||||
{/* Card header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 18 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#fff" }}>Vermogensoverzicht</div>
|
||||
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.5)", marginTop: 2 }}>The Road naar € 500.000</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.5)", background: "rgba(255,255,255,0.1)", borderRadius: 6, padding: "3px 8px" }}>Mei 2026</div>
|
||||
</div>
|
||||
|
||||
{/* KPI rows */}
|
||||
{[
|
||||
{ label: "Eigen Vermogen", val: "€ 445.500", color: "#10b981", icon: "↑" },
|
||||
{ label: "Schuld", val: "€ 54.500", color: "#f97316", icon: "↓" },
|
||||
{ label: "Winst", val: "€ 142.500", color: "#a855f7", icon: "↑" },
|
||||
].map(({ label, val, color, icon }) => (
|
||||
<div key={label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ width: 26, height: 26, borderRadius: 7, background: `${color}22`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, color, fontWeight: 700 }}>{icon}</div>
|
||||
<span style={{ fontSize: 12, color: "rgba(255,255,255,0.7)" }}>{label}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "#fff" }}>{val}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Progress toward goal */}
|
||||
<div style={{ marginTop: 16, padding: "12px 14px", background: "rgba(0,0,0,0.2)", borderRadius: 10 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.6)" }}>Voortgang naar doel</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: "#fff" }}>89%</span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: "rgba(255,255,255,0.12)", borderRadius: 99, overflow: "hidden" }}>
|
||||
<div style={{ width: "89%", height: "100%", background: "linear-gradient(90deg, #a855f7, #c084fc)", borderRadius: 99 }} />
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6 }}>
|
||||
<span style={{ fontSize: 9, color: "rgba(255,255,255,0.35)" }}>€ 0</span>
|
||||
<span style={{ fontSize: 9, color: "rgba(255,255,255,0.35)" }}>€ 500.000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini bar chart */}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.45)", marginBottom: 6 }}>Groei per maand</div>
|
||||
<MiniBarChart />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating badges */}
|
||||
<div style={{ display: "flex", gap: 12, position: "relative", zIndex: 2 }}>
|
||||
<div style={{
|
||||
background: "rgba(255,255,255,0.95)", borderRadius: 14, padding: "12px 16px",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
|
||||
}}>
|
||||
<div style={{ fontSize: 10, color: "#64748b", fontWeight: 600, marginBottom: 3 }}>ROI</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 800, color: "#10b981" }}>+47,03%</div>
|
||||
</div>
|
||||
<div style={{
|
||||
background: "rgba(255,255,255,0.95)", borderRadius: 14, padding: "12px 16px",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
|
||||
}}>
|
||||
<div style={{ fontSize: 10, color: "#64748b", fontWeight: 600, marginBottom: 3 }}>Doel</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 800, color: "#7c3aed" }}>€ 500.000</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom tagline */}
|
||||
<div style={{ position: "absolute", bottom: 24, left: 40, right: 40, fontSize: 12, color: "rgba(255,255,255,0.4)", lineHeight: 1.6, textAlign: "center" }}>
|
||||
Beheer je vermogen, volg je groei en bereik je financiële doelen — allemaal op één plek.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { TABS } from "../constants/index.js";
|
||||
import { version, updateCheckUrl } from "../../package.json";
|
||||
import { version } from "../../package.json";
|
||||
import { useUpdateCheck } from "../hooks/useUpdateCheck.js";
|
||||
import { useApp } from "../context/AppContext.jsx";
|
||||
|
||||
const TAB_ICONS = [
|
||||
@@ -123,7 +124,7 @@ function OverPopup({ onClose, nieuweVersie, T }) {
|
||||
}}>
|
||||
{/* Modal */}
|
||||
<div onClick={(e) => e.stopPropagation()} style={{
|
||||
background: T.card,
|
||||
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)",
|
||||
@@ -166,7 +167,7 @@ function OverPopup({ onClose, nieuweVersie, T }) {
|
||||
<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}
|
||||
v{nieuweVersie.latest}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -213,20 +214,9 @@ function DarkModeToggle({ darkMode, setDarkMode, T }) {
|
||||
|
||||
export default function NavBar() {
|
||||
const { tab, setTab, setShowUsers, setShowInstellingen, T, logout, darkMode, setDarkMode, currentUser, avatar } = useApp();
|
||||
const [nieuweVersie, setNieuweVersie] = useState(null);
|
||||
const nieuweVersie = useUpdateCheck();
|
||||
const [overOpen, setOverOpen] = useState(false);
|
||||
|
||||
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: 260, minWidth: 260, flexShrink: 0,
|
||||
|
||||
@@ -90,7 +90,8 @@ export default function ProfielPopup({ onClose }) {
|
||||
|
||||
const popupStyle = {
|
||||
position: "fixed", top: 64, right: 16, zIndex: 199,
|
||||
background: T.card, border: `1px solid ${T.border}`,
|
||||
background: T.popupBg,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 16, width: 340,
|
||||
boxShadow: "0 16px 48px rgba(0,0,0,0.45)",
|
||||
overflow: "hidden",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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);
|
||||
|
||||
@@ -64,75 +65,109 @@ export function VProgressBar({ value, max, color = PURPLE }) {
|
||||
|
||||
// ── 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 GAP = active.length > 1 ? circ * 0.022 : 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 dash = Math.max(raw - GAP, 2);
|
||||
const sl = { ...s, dash, offset: off };
|
||||
const sl = { ...s, dash: Math.max(raw, 1), offset: off, share: total ? s.val / total : 0 };
|
||||
off += raw;
|
||||
return sl;
|
||||
});
|
||||
const glowId = `dgl${size}`;
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<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} />
|
||||
|
||||
{/* Dark inner fill */}
|
||||
<circle cx={cx} cy={cy} r={r - strokeW / 2 + 1} fill="rgba(0,0,0,0.55)" />
|
||||
{/* 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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Track */}
|
||||
<circle cx={cx} cy={cy} r={r} fill="none"
|
||||
stroke="rgba(255,255,255,0.06)" strokeWidth={strokeW} />
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
)}
|
||||
{/* 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -289,6 +324,7 @@ export function Overlay({ children, onClose, zIndex = 400 }) {
|
||||
}
|
||||
|
||||
export function ModalBox({ children, maxWidth = 480 }) {
|
||||
const { T } = useApp();
|
||||
const ref = useRef(null);
|
||||
const [pos, setPos] = useState(null);
|
||||
const dragState = useRef(null);
|
||||
@@ -331,8 +367,8 @@ export function ModalBox({ children, maxWidth = 480 }) {
|
||||
<div ref={ref} style={{
|
||||
position: "fixed",
|
||||
...posStyle,
|
||||
background: "#1a1d27",
|
||||
border: "1px solid rgba(255,255,255,0.07)",
|
||||
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",
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
* Wordt gebruikt door alle componenten om consistent kleuren te krijgen.
|
||||
*/
|
||||
export function useTheme(darkMode) {
|
||||
const bg = darkMode ? "#241A39" : "#eef1f8";
|
||||
const card = darkMode ? "rgba(22,23,27,0.30)" : "#ffffff";
|
||||
return {
|
||||
// Exacte Finex kleuren (dark) / licht alternatief
|
||||
bg: darkMode ? "#241A39" : "#eef1f8",
|
||||
card: darkMode ? "rgba(22,23,27,0.30)" : "#ffffff",
|
||||
bg,
|
||||
card,
|
||||
// Ondoorzichtige achtergrond voor popups/modals: kaartkleur over de pagina-bg.
|
||||
popupBg: `linear-gradient(${card}, ${card}), ${bg}`,
|
||||
sidebar: darkMode ? "#16171B" : "#f8faff",
|
||||
border: darkMode ? "rgba(139,92,246,0.15)" : "rgba(0,0,0,0.07)",
|
||||
muted: darkMode ? "#8b8fa8" : "#94a3b8",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { version } from "../../package.json";
|
||||
|
||||
// De check loopt via de eigen backend (/api/update-check): de Gitea API stuurt
|
||||
// geen CORS-headers, dus de browser mag die niet direct bevragen.
|
||||
const POLL_MS = 30 * 60 * 1000; // periodiek opnieuw kijken
|
||||
const MIN_GAP_MS = 5 * 60 * 1000; // niet vaker dan dit, ook niet bij focus
|
||||
|
||||
/**
|
||||
* Vergelijkt twee semver-strings. Een pre-release suffix (`-dev`) telt als
|
||||
* lager dan dezelfde versie zonder suffix.
|
||||
*/
|
||||
export function isNieuwer(latest, huidig) {
|
||||
const parse = (v) => {
|
||||
const [core, pre] = String(v ?? "").replace(/^v/, "").split("-");
|
||||
const n = core.split(".").map((x) => parseInt(x, 10) || 0);
|
||||
return { n: [n[0] ?? 0, n[1] ?? 0, n[2] ?? 0], pre: pre || null };
|
||||
};
|
||||
const a = parse(latest), b = parse(huidig);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a.n[i] !== b.n[i]) return a.n[i] > b.n[i];
|
||||
}
|
||||
return !a.pre && !!b.pre;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geeft de nieuwste release terug zodra die nieuwer is dan de draaiende versie,
|
||||
* anders `null`. Vorm: { latest, naam, notes, url, publishedAt }.
|
||||
*/
|
||||
export function useUpdateCheck() {
|
||||
const [update, setUpdate] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Dev-builds lopen per definitie voor op de laatste release — geen melding.
|
||||
// Met ?updatecheck in de URL forceer je de check alsnog (voor testen).
|
||||
const forceer = new URLSearchParams(window.location.search).has("updatecheck");
|
||||
if (version.includes("-") && !forceer) return;
|
||||
|
||||
let gestopt = false;
|
||||
let laatste = 0;
|
||||
|
||||
const check = async () => {
|
||||
if (gestopt || Date.now() - laatste < MIN_GAP_MS) return;
|
||||
laatste = Date.now();
|
||||
try {
|
||||
const res = await fetch("/api/update-check");
|
||||
if (!res.ok || gestopt) return;
|
||||
const rel = await res.json();
|
||||
if (gestopt) return;
|
||||
setUpdate(rel?.latest && isNieuwer(rel.latest, version) ? rel : null);
|
||||
} catch {
|
||||
// Backend onbereikbaar of offline: gewoon geen melding tonen.
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
const iv = setInterval(check, POLL_MS);
|
||||
window.addEventListener("focus", check);
|
||||
return () => {
|
||||
gestopt = true;
|
||||
clearInterval(iv);
|
||||
window.removeEventListener("focus", check);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return update;
|
||||
}
|
||||
@@ -539,7 +539,7 @@ export default function EigenVermogenTab() {
|
||||
flex: 1, minWidth: 200,
|
||||
display: "flex", flexDirection: "column", alignItems: "center",
|
||||
}}>
|
||||
<DonutChart segments={catTotals.map((c) => ({ val: c.tot, color: c.color }))} total={totVerdeling} size={200} label="Verdeling" centerVal={fmt(totVerdeling)} />
|
||||
<DonutChart segments={catTotals.map((c) => ({ val: c.tot, color: c.color, naam: c.naam }))} total={totVerdeling} size={200} label="Verdeling" centerVal={fmt(totVerdeling)} />
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px 14px", marginTop: 12, justifyContent: "center" }}>
|
||||
{catTotals.map((c) => (
|
||||
<div key={c.id} style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||
|
||||
@@ -375,7 +375,7 @@ export default function SchuldTab() {
|
||||
display: "flex", flexDirection: "column", alignItems: "center",
|
||||
}}>
|
||||
<DonutChart
|
||||
segments={schuldCats.map((b) => ({ val: b.subtot, color: b.color }))}
|
||||
segments={schuldCats.map((b) => ({ val: b.subtot, color: b.color, naam: b.naam }))}
|
||||
total={donutTotal} size={200} label="Totaal schuld" centerVal={fmt(donutTotal)}
|
||||
/>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px 14px", marginTop: 12, justifyContent: "center" }}>
|
||||
|
||||
@@ -347,7 +347,7 @@ export default function VoortgangTab() {
|
||||
|
||||
{/* Donut */}
|
||||
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderRadius: 16, display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<DonutChart segments={[{ val: totEV, color: GREEN }, { val: totSchuld, color: RED }]} total={totEV + totSchuld || 1} size={200} label="Netto waarde" centerVal={fmt(totEV - totSchuld)} />
|
||||
<DonutChart segments={[{ val: totEV, color: GREEN, naam: "Eigen Vermogen" }, { val: totSchuld, color: RED, naam: "Schuld" }]} total={totEV + totSchuld || 1} size={200} label="Netto waarde" centerVal={fmt(totEV - totSchuld)} />
|
||||
<div style={{ display: "flex", gap: 20, marginTop: 12, justifyContent: "center" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: "50%", background: GREEN }} />
|
||||
|
||||
+78
-4
@@ -14,6 +14,8 @@
|
||||
# - docker buildx builder actief: docker buildx create --use
|
||||
# - Git geïnstalleerd en working directory is de repo root
|
||||
# - root package.json bevat een schoon versienummer ZONDER -dev suffix
|
||||
# - $env:GITEA_TOKEN gezet (token met release-rechten) — nodig voor stap 4,
|
||||
# de Gitea-release waar de update-melding in de app op afgaat
|
||||
|
||||
param(
|
||||
[switch]$NoBuild,
|
||||
@@ -87,6 +89,27 @@ function Write-Step {
|
||||
Write-Host "── $Label" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# Haalt de sectie van deze versie uit Changelog.md als release-notes.
|
||||
function Get-ChangelogSection {
|
||||
param([string]$Version)
|
||||
$path = Join-Path $PSScriptRoot "Changelog.md"
|
||||
if (-not (Test-Path $path)) { return "" }
|
||||
$lines = Get-Content $path
|
||||
$start = -1
|
||||
$needle = "^##\s*\[" + [regex]::Escape($Version) + "\]"
|
||||
for ($i = 0; $i -lt $lines.Count; $i++) {
|
||||
if ($lines[$i] -match $needle) { $start = $i + 1; break }
|
||||
}
|
||||
if ($start -lt 0) { return "" }
|
||||
$out = @()
|
||||
for ($i = $start; $i -lt $lines.Count; $i++) {
|
||||
if ($lines[$i] -match "^##\s*\[") { break }
|
||||
if ($lines[$i] -match "^---\s*$") { continue }
|
||||
$out += $lines[$i]
|
||||
}
|
||||
return (($out -join "`n").Trim())
|
||||
}
|
||||
|
||||
# ── Samenvatting ───────────────────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
|
||||
@@ -100,7 +123,7 @@ if ($Force) { Write-Host " Mode : FORCE (conflictcontrole overgeslagen)" -
|
||||
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
|
||||
|
||||
# ── Stap 1: Conflictcontrole ──────────────────────────────────────────────
|
||||
Write-Step "[1/3] Conflictcontrole"
|
||||
Write-Step "[1/4] Conflictcontrole"
|
||||
|
||||
if (-not $DryRun -and -not $Force) {
|
||||
$localTag = git tag --list $gitTag
|
||||
@@ -144,7 +167,7 @@ if (-not $DryRun -and -not $Force) {
|
||||
|
||||
# ── Stap 2: Bouwen en pushen ──────────────────────────────────────────────
|
||||
if (-not $NoBuild) {
|
||||
Write-Step "[2/3] Multi-platform bouwen en pushen"
|
||||
Write-Step "[2/4] Multi-platform bouwen en pushen"
|
||||
|
||||
Write-Host "Backend bouwen en pushen..." -ForegroundColor White
|
||||
Invoke-Cmd @(
|
||||
@@ -169,11 +192,11 @@ if (-not $NoBuild) {
|
||||
(Join-Path $PSScriptRoot "frontend")
|
||||
)
|
||||
} else {
|
||||
Write-Step "[2/3] Bouwen overgeslagen (-NoBuild)"
|
||||
Write-Step "[2/4] Bouwen overgeslagen (-NoBuild)"
|
||||
}
|
||||
|
||||
# ── Stap 3: Git-tag aanmaken en pushen ────────────────────────────────────
|
||||
Write-Step "[3/3] Git-tag aanmaken en pushen"
|
||||
Write-Step "[3/4] Git-tag aanmaken en pushen"
|
||||
|
||||
if ($Force) {
|
||||
Write-Host "Bestaande lokale tag verwijderen (indien aanwezig)..." -ForegroundColor Yellow
|
||||
@@ -192,6 +215,57 @@ Invoke-Cmd @("git", "tag", "-a", $gitTag, "-m", "Release $gitTag")
|
||||
Write-Host "Git-tag pushen naar origin" -ForegroundColor White
|
||||
Invoke-Cmd @("git", "push", "origin", "refs/tags/$gitTag")
|
||||
|
||||
# ── Stap 4: Gitea-release aanmaken ────────────────────────────────────────
|
||||
Write-Step "[4/4] Gitea-release aanmaken"
|
||||
|
||||
# De update-melding in de app leest /releases/latest. Een kale Git-tag komt
|
||||
# daar niet in terug, dus er moet een echte release van gemaakt worden.
|
||||
$repoUrl = ($rootPkg.repository -replace "/+$", "")
|
||||
$releaseApi = $null
|
||||
if ($repoUrl -match "^(https?://[^/]+)/([^/]+)/([^/]+)$") {
|
||||
$releaseApi = "$($Matches[1])/api/v1/repos/$($Matches[2])/$($Matches[3])/releases"
|
||||
}
|
||||
$giteaToken = $env:GITEA_TOKEN
|
||||
|
||||
if (-not $releaseApi) {
|
||||
Write-Host "Kon 'repository' niet parsen uit package.json — release overgeslagen." -ForegroundColor Yellow
|
||||
} elseif (-not $giteaToken) {
|
||||
Write-Host "Geen GITEA_TOKEN in de omgeving — release overgeslagen." -ForegroundColor Yellow
|
||||
Write-Host " Zonder release ziet de app geen update-melding voor $appVersion." -ForegroundColor DarkGray
|
||||
Write-Host ' Token met release-rechten zetten: $env:GITEA_TOKEN = "<token>"' -ForegroundColor DarkGray
|
||||
} else {
|
||||
$notes = Get-ChangelogSection $appVersion
|
||||
if (-not $notes) { $notes = "Release $appVersion" }
|
||||
$payload = @{
|
||||
tag_name = $gitTag
|
||||
name = "v$appVersion"
|
||||
body = $notes
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
} | ConvertTo-Json -Depth 3
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host "[DRY-RUN] POST $releaseApi" -ForegroundColor Yellow
|
||||
Write-Host $payload -ForegroundColor DarkGray
|
||||
} else {
|
||||
Write-Host "Release publiceren: $releaseApi" -ForegroundColor White
|
||||
try {
|
||||
$rel = Invoke-RestMethod -Method Post -Uri $releaseApi -Body $payload `
|
||||
-ContentType "application/json" `
|
||||
-Headers @{ Authorization = "token $giteaToken" }
|
||||
Write-Host "Release aangemaakt: $($rel.html_url)" -ForegroundColor Green
|
||||
} catch {
|
||||
$code = $_.Exception.Response.StatusCode.value__
|
||||
if ($code -eq 409) {
|
||||
Write-Host "Release voor $gitTag bestaat al — overgeslagen." -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Release aanmaken mislukt ($code): $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host "Tag en images staan al klaar; maak de release eventueel handmatig aan." -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ── Klaar ─────────────────────────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
# - Ingelogd op de registry: docker login vibetea.vldn.net
|
||||
# - Git geïnstalleerd
|
||||
# - root package.json bevat versienummer ZONDER -dev suffix
|
||||
# - GITEA_TOKEN in de omgeving (token met release-rechten) — nodig voor het
|
||||
# aanmaken van de Gitea-release waar de update-melding op afgaat
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
Reference in New Issue
Block a user