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:
Jordy Hoogeveen
2026-09-04 09:19:23 +02:00
co-authored by Claude Opus 5
parent e45d5b73fb
commit 3c67381de9
24 changed files with 506 additions and 241 deletions
+41 -4
View File
@@ -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.
+22
View File
@@ -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
+7
View File
@@ -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,
};
+52
View File
@@ -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;
+2
View File
@@ -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);
+3 -4
View File
@@ -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
+1
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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": {
+2 -2
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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,
+96 -132
View File
@@ -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>
);
+6 -16
View File
@@ -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,
+2 -1
View File
@@ -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",
+75 -39
View File
@@ -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>
{/* Dark inner fill */}
<circle cx={cx} cy={cy} r={r - strokeW / 2 + 1} fill="rgba(0,0,0,0.55)" />
{/* Track */}
<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="rgba(255,255,255,0.06)" strokeWidth={strokeW} />
stroke={darkMode ? "rgba(255,255,255,0.07)" : "rgba(15,23,42,0.07)"}
strokeWidth={strokeW} />
{/* 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 */}
{/* Segmenten */}
{slices.map((s, i) => (
<circle key={`c${i}`} cx={cx} cy={cy} r={r} fill="none"
stroke={s.color} strokeWidth={strokeW}
stroke={s.color} strokeWidth={hover && hover.i === i ? strokeW * 1.1 : strokeW}
strokeDasharray={`${s.dash} ${circ - s.dash}`}
strokeDashoffset={-s.offset + circ / 4}
strokeLinecap="round"
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 - size * 0.1} fill="rgba(255,255,255,0.4)"
fontSize={size * 0.09} textAnchor="middle" dominantBaseline="middle"
fontFamily="inherit">
<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={cy + size * 0.09} fill="#fff"
fontSize={size * 0.155} fontWeight="800" textAnchor="middle" dominantBaseline="middle"
fontFamily="inherit">
<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>
);
}
@@ -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",
+6 -2
View File
@@ -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",
+67
View File
@@ -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;
}
+1 -1
View File
@@ -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 }}>
+1 -1
View File
@@ -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" }}>
+1 -1
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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