diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..682a971 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0507fd4..d231b8f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -10,6 +10,7 @@ import SchuldTab from "./pages/SchuldTab.jsx"; import VoortgangTab from "./pages/VoortgangTab.jsx"; import { RED, PURPLE, PURPLE_LIGHT } from "./constants/index.js"; import ProfielPopup from "./components/ProfielPopup.jsx"; +import InstellingenModal from "./components/InstellingenModal.jsx"; function initials(naam = "") { const parts = naam.trim().split(/\s+/); @@ -20,11 +21,11 @@ function initials(naam = "") { const PAGES = [DashboardTab, EigenVermogenTab, SchuldTab, VoortgangTab]; function AppInner() { - const { loggedIn, login, logout, locked, tab, showUsers, setShowUsers, loading, T, darkMode, setDarkMode, currentUser, avatar } = useApp(); + const { loggedIn, login, logout, locked, tab, showUsers, setShowUsers, showInstellingen, setShowInstellingen, loading, T, currentUser, avatar, idleMinutes } = useApp(); const [profielOpen, setProfielOpen] = useState(false); const idleTimer = useRef(null); - const IDLE_MS = 30 * 60 * 1000; + const IDLE_MS = (idleMinutes ?? 30) * 60 * 1000; const [sessionVerlopen, setSessionVerlopen] = useState(false); useEffect(() => { @@ -131,21 +132,9 @@ function AppInner() { : initials(currentUser?.naam || "") } - - - {profielOpen && setProfielOpen(false)} />} - {/* Vergrendeld banner */} @@ -166,8 +155,9 @@ function AppInner() { )} - {/* Gebruikersbeheer modal */} - {showUsers && setShowUsers(false)} />} + {/* Modals */} + {showUsers && setShowUsers(false)} />} + {showInstellingen && setShowInstellingen(false)} />} {/* Actieve pagina */}
diff --git a/frontend/src/components/GebruikersBeheer.jsx b/frontend/src/components/GebruikersBeheer.jsx index c8fe6b6..83ac7d2 100644 --- a/frontend/src/components/GebruikersBeheer.jsx +++ b/frontend/src/components/GebruikersBeheer.jsx @@ -1,6 +1,5 @@ import { useState, useEffect, useRef } from "react"; import { PURPLE, PURPLE_LIGHT, RED, GREEN } from "../constants/index.js"; -import { Overlay, ModalBox, ModalHeader } from "./ui/index.jsx"; import { useApp } from "../context/AppContext.jsx"; const getToken = () => localStorage.getItem("vf_token"); @@ -168,19 +167,44 @@ export default function GebruikersBeheer({ onClose }) { const td = { padding: "10px 12px", fontSize: 13, color: T.text, borderBottom: `1px solid ${T.border}` }; return ( - - - +
+
e.stopPropagation()} + style={{ + background: T.card, 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)", + overflow: "hidden", + }} + > + {/* Header */} +
+
+ Gebruikersbeheer + + {users.length} gebruiker{users.length !== 1 ? "s" : ""} + +
+ +
-
+
{loadErr && (
{loadErr}
)} @@ -342,14 +366,7 @@ export default function GebruikersBeheer({ onClose }) { )}
-
- -
- - +
+
); } diff --git a/frontend/src/components/InstellingenModal.jsx b/frontend/src/components/InstellingenModal.jsx new file mode 100644 index 0000000..4bdb1c5 --- /dev/null +++ b/frontend/src/components/InstellingenModal.jsx @@ -0,0 +1,111 @@ +import { useApp } from "../context/AppContext.jsx"; +import { PURPLE } from "../constants/index.js"; + +const BACKUP_ICON = ( + + + + +); + +const RESTORE_ICON = ( + + + + +); + +function ActionRow({ icon, title, description, onClick }) { + return ( +
+
{icon}
+
+
{title}
+
{description}
+
+
+ ); +} + +export default function InstellingenModal({ onClose }) { + const { T, darkMode, setDarkMode, doBackup, doRestore } = useApp(); + + return ( +
+
e.stopPropagation()} + style={{ + background: T.card, 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, + }} + > + {/* Header */} +
+ Instellingen + +
+ + {/* Dark mode rij */} +
+ Dark mode +
setDarkMode((d) => !d)} + style={{ + width: 44, height: 24, borderRadius: 12, cursor: "pointer", + background: darkMode ? `linear-gradient(135deg, ${PURPLE}, #a855f7)` : "#334155", + position: "relative", transition: "background 0.2s", flexShrink: 0, + }} + > +
+
+
+ +
+ + {/* Acties */} +
+ { doBackup(); onClose(); }} + /> +
+ { doRestore(); onClose(); }} + /> +
+
+
+ ); +} diff --git a/frontend/src/components/LoginPage.jsx b/frontend/src/components/LoginPage.jsx index 21c045b..88ee923 100644 --- a/frontend/src/components/LoginPage.jsx +++ b/frontend/src/components/LoginPage.jsx @@ -1,20 +1,82 @@ import { useState } from "react"; import { PURPLE, PURPLE_LIGHT, RED } from "../constants/index.js"; -export default function LoginPage({ onLogin }) { - const [mode, setMode] = useState("login"); - const [naam, setNaam] = useState(""); - const [email, setEmail] = useState(""); - const [wachtwoord, setWachtwoord] = useState(""); - const [showPass, setShowPass] = useState(false); - const [err, setErr] = useState(""); - const [loading, setLoading] = useState(false); +const PersonIcon = () => ( + + + + +); +const MailIcon = () => ( + + + + +); +const LockIcon = () => ( + + + + +); - const switchMode = (m) => { setMode(m); setNaam(""); setEmail(""); setWachtwoord(""); setErr(""); }; +function EyeBtn({ show, onToggle, absolute = false }) { + const style = absolute + ? { position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)" } + : { position: "absolute", right: 10, top: "50%", transform: "translateY(-50%)" }; + return ( + + ); +} + +function FieldCard({ icon, label, children, extra }) { + return ( +
+ {icon} +
+
{label}
+
+ {children} + {extra} +
+
+
+ ); +} + +export default function LoginPage({ onLogin }) { + const [mode, setMode] = useState("login"); + const [naam, setNaam] = useState(""); + const [email, setEmail] = useState(""); + const [wachtwoord, setWachtwoord] = useState(""); + const [herhaalWw, setHerhaalWw] = useState(""); + const [showPass, setShowPass] = useState(false); + const [showPass2, setShowPass2] = useState(false); + const [err, setErr] = useState(""); + const [loading, setLoading] = useState(false); + + const switchMode = (m) => { setMode(m); setNaam(""); setEmail(""); setWachtwoord(""); setHerhaalWw(""); setErr(""); }; const handleSubmit = async () => { setErr(""); - if (mode === "register" && !naam.trim()) { setErr("Vul je naam in."); return; } + if (mode === "register") { + if (!naam.trim()) { setErr("Vul je naam in."); return; } + if (wachtwoord.length < 8) { setErr("Wachtwoord moet minimaal 8 tekens zijn."); return; } + if (!/[A-Z]/.test(wachtwoord)) { setErr("Wachtwoord moet minimaal één hoofdletter bevatten."); return; } + if (!/[0-9]/.test(wachtwoord)) { setErr("Wachtwoord moet minimaal één cijfer bevatten."); return; } + if (wachtwoord !== herhaalWw) { setErr("Wachtwoorden komen niet overeen."); return; } + } if (!email || !wachtwoord) { setErr("Vul e-mail en wachtwoord in."); return; } setLoading(true); try { @@ -33,11 +95,8 @@ export default function LoginPage({ onLogin }) { }; const iStyle = { - width: "100%", - background: "rgba(255,255,255,0.06)", - border: "1px solid rgba(255,255,255,0.1)", - borderRadius: 8, color: "#f1f5f9", - padding: "11px 14px", fontSize: 13, + width: "100%", background: "transparent", border: "none", + color: "#f1f5f9", padding: "0", fontSize: 13, outline: "none", boxSizing: "border-box", }; @@ -80,7 +139,7 @@ export default function LoginPage({ onLogin }) {
{/* Heading */} -
+

{mode === "login" ? "Welkom terug!" : "Account aanmaken"}

@@ -91,36 +150,86 @@ export default function LoginPage({ onLogin }) {

+ {/* Tab switcher */} +
+ {[["login", "Inloggen"], ["register", "Registreren"]].map(([m, label]) => ( + + ))} +
+ {/* Formulier */} -
- {mode === "register" && ( -
- { setNaam(e.target.value); setErr(""); }} - onKeyDown={(e) => e.key === "Enter" && handleSubmit()} - style={iStyle} /> -
+
+ {mode === "register" ? ( + <> + {/* Volledige naam */} + } label="Volledige naam"> + { setNaam(e.target.value); setErr(""); }} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} + style={iStyle} /> + + + {/* E-mail */} + } label="E-mailadres"> + { setEmail(e.target.value); setErr(""); }} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} + style={iStyle} /> + + + {/* Wachtwoord */} + } label="Wachtwoord" extra={ + setShowPass((s) => !s)} /> + }> + { setWachtwoord(e.target.value); setErr(""); }} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} + style={{ ...iStyle, paddingRight: 36 }} /> + + + {/* Herhaal wachtwoord */} + } label="Herhaal wachtwoord" extra={ + setShowPass2((s) => !s)} /> + }> + { setHerhaalWw(e.target.value); setErr(""); }} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} + style={{ ...iStyle, paddingRight: 36 }} /> + + +
+ Min. 8 tekens · één hoofdletter · één cijfer +
+ + ) : ( + <> + } label="E-mailadres"> + { setEmail(e.target.value); setErr(""); }} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} + style={iStyle} /> + + + } label="Wachtwoord" extra={ + setShowPass((s) => !s)} /> + }> + { setWachtwoord(e.target.value); setErr(""); }} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} + style={{ ...iStyle, paddingRight: 36 }} /> + + )} -
- { setEmail(e.target.value); setErr(""); }} - onKeyDown={(e) => e.key === "Enter" && handleSubmit()} - style={iStyle} /> -
-
-
- { setWachtwoord(e.target.value); setErr(""); }} - onKeyDown={(e) => e.key === "Enter" && handleSubmit()} - style={{ ...iStyle, paddingRight: 42 }} /> - -
-
{err && ( @@ -145,20 +254,6 @@ export default function LoginPage({ onLogin }) { - {/* Switch mode */} -
- {mode === "login" ? ( - - Nog geen account?{" "} - - - ) : ( - - Al een account?{" "} - - - )} -
{/* Rechts: app mockup β€” flat, groot, licht zwevend */} diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx index a464c77..4acaef4 100644 --- a/frontend/src/components/NavBar.jsx +++ b/frontend/src/components/NavBar.jsx @@ -35,10 +35,9 @@ const GEBRUIKERS_ICON = ( ); -const DATA_ICON = ( - - - +const INSTELLINGEN_ICON = ( + + ); @@ -95,13 +94,12 @@ export default function NavBar() { const { tab, setTab, setShowUsers, + setShowInstellingen, T, logout, - doBackup, doRestore, } = useApp(); const [nieuweVersie, setNieuweVersie] = useState(null); - const [dataOpen, setDataOpen] = useState(false); const [collapsed, setCollapsed] = useState(false); const W = collapsed ? 56 : 220; @@ -197,62 +195,13 @@ export default function NavBar() { collapsed={collapsed} /> - {/* Data */} - {collapsed ? ( -
- setDataOpen((o) => !o)} - icon={DATA_ICON} - label="Data" - T={T} - collapsed={collapsed} - /> - {dataOpen && ( -
-
{ doBackup(); setDataOpen(false); }} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 14px", cursor: "pointer", color: T.muted, fontSize: 12 }}> - - Backup maken -
-
{ doRestore(); setDataOpen(false); }} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 14px", cursor: "pointer", color: T.muted, fontSize: 12 }}> - - Backup herstellen -
-
- )} -
- ) : ( -
-
setDataOpen((o) => !o)} style={{ - display: "flex", alignItems: "center", gap: 10, - padding: "9px 12px", borderRadius: 8, cursor: "pointer", marginBottom: 6, - color: T.muted, fontSize: 13, fontWeight: 500, - }}> - {DATA_ICON} - Data - - - -
- {dataOpen && ( -
-
{ doBackup(); setDataOpen(false); }} style={{ display: "flex", alignItems: "center", gap: 8, padding: "7px 12px", borderRadius: 8, cursor: "pointer", color: T.muted, fontSize: 12 }}> - - Backup maken -
-
{ doRestore(); setDataOpen(false); }} style={{ display: "flex", alignItems: "center", gap: 8, padding: "7px 12px", borderRadius: 8, cursor: "pointer", color: T.muted, fontSize: 12 }}> - - Backup herstellen -
-
- )} -
- )} + setShowInstellingen(true)} + icon={INSTELLINGEN_ICON} + label="Instellingen" + T={T} + collapsed={collapsed} + /> {/* Onderste sectie */} diff --git a/frontend/src/components/ProfielPopup.jsx b/frontend/src/components/ProfielPopup.jsx index 284e4a2..aeae99b 100644 --- a/frontend/src/components/ProfielPopup.jsx +++ b/frontend/src/components/ProfielPopup.jsx @@ -8,225 +8,256 @@ function initials(naam = "") { return naam.slice(0, 2).toUpperCase(); } +const LOCK_ICON = ( + + + + +); + +const LOGOUT_ICON = ( + + + + + +); + +const CHEVRON = ( + + + +); + +const BACK_ICON = ( + + + +); + +function InputField({ label, type = "text", value, onChange, T }) { + return ( +
+ + +
+ ); +} + export default function ProfielPopup({ onClose }) { const { currentUser, updateProfile, changePassword, logout, T, avatar, setAvatar } = useApp(); - const [activeTab, setActiveTab] = useState("profiel"); + const [view, setView] = useState("main"); const [naam, setNaam] = useState(currentUser?.naam || ""); const [email, setEmail] = useState(currentUser?.email || ""); - const [profielError, setProfielError] = useState(""); - const [profielSuccess, setProfielSuccess] = useState(false); - const [profielLoading, setProfielLoading] = useState(false); + const [profielErr, setProfielErr] = useState(""); + const [profielOk, setProfielOk] = useState(false); + const [profielBusy, setProfielBusy] = useState(false); - const [huidig, setHuidig] = useState(""); - const [nieuw, setNieuw] = useState(""); - const [bevestig, setBevestig] = useState(""); - const [pwError, setPwError] = useState(""); - const [pwSuccess, setPwSuccess] = useState(false); - const [pwLoading, setPwLoading] = useState(false); + const [huidig, setHuidig] = useState(""); + const [nieuw, setNieuw] = useState(""); + const [bevestig, setBevestig] = useState(""); + const [pwErr, setPwErr] = useState(""); + const [pwOk, setPwOk] = useState(false); + const [pwBusy, setPwBusy] = useState(false); - const pIs = { - background: T.inputBg, border: `1px solid ${T.inputBorder}`, - borderRadius: 10, color: T.text, padding: "10px 12px", - fontSize: 13, outline: "none", width: "100%", boxSizing: "border-box", + const saveProfiel = async () => { + setProfielErr(""); setProfielOk(false); + if (!naam.trim() || !email.trim()) { setProfielErr("Naam en e-mail zijn verplicht."); return; } + setProfielBusy(true); + try { await updateProfile(naam.trim(), email.trim()); setProfielOk(true); } + catch (err) { setProfielErr(err.message || "Opslaan mislukt."); } + finally { setProfielBusy(false); } }; - const handleSaveProfiel = async () => { - setProfielError(""); setProfielSuccess(false); - if (!naam.trim() || !email.trim()) { setProfielError("Naam en e-mail zijn verplicht."); return; } - setProfielLoading(true); - try { - await updateProfile(naam.trim(), email.trim()); - setProfielSuccess(true); - } catch (err) { - setProfielError(err.message || "Opslaan mislukt."); - } finally { - setProfielLoading(false); - } + const saveWachtwoord = async () => { + setPwErr(""); setPwOk(false); + if (!huidig || !nieuw || !bevestig) { setPwErr("Vul alle velden in."); return; } + if (nieuw !== bevestig) { setPwErr("Nieuwe wachtwoorden komen niet overeen."); return; } + if (nieuw.length < 8) { setPwErr("Minimaal 8 tekens."); return; } + setPwBusy(true); + try { await changePassword(huidig, nieuw); setPwOk(true); setHuidig(""); setNieuw(""); setBevestig(""); } + catch (err) { setPwErr(err.message || "Wijzigen mislukt."); } + finally { setPwBusy(false); } }; - const handleChangePassword = async () => { - setPwError(""); setPwSuccess(false); - if (!huidig || !nieuw || !bevestig) { setPwError("Vul alle velden in."); return; } - if (nieuw !== bevestig) { setPwError("Nieuwe wachtwoorden komen niet overeen."); return; } - if (nieuw.length < 8) { setPwError("Nieuw wachtwoord moet minimaal 8 tekens zijn."); return; } - setPwLoading(true); - try { - await changePassword(huidig, nieuw); - setPwSuccess(true); - setHuidig(""); setNieuw(""); setBevestig(""); - } catch (err) { - setPwError(err.message || "Wachtwoord wijzigen mislukt."); - } finally { - setPwLoading(false); - } + const popupStyle = { + position: "fixed", top: 64, right: 16, zIndex: 199, + background: T.card, border: `1px solid ${T.border}`, + borderRadius: 16, width: 340, + boxShadow: "0 16px 48px rgba(0,0,0,0.45)", + overflow: "hidden", }; + const headerStyle = { + display: "flex", alignItems: "center", justifyContent: "center", + padding: "16px 16px 14px", position: "relative", + borderBottom: `1px solid ${T.border}`, + }; + + const closeBtn = ( + + ); + + const backBtn = (label) => ( + + ); + + const actionRow = (icon, label, onClick, color) => ( +
+ {icon} + {label} + {!color && {CHEVRON}} +
+ ); + return ( <> - {/* Backdrop */}
- {/* Popup */} -
- {/* Header */} -
-
-
- {avatar - ? avatar - : initials(currentUser?.naam) - } -
- -
-
-
- {currentUser?.naam} -
-
- {currentUser?.email} -
-
- -
+
- {/* Tabs */} -
- {[ - { id: "profiel", label: "πŸ‘€ Profiel" }, - { id: "wachtwoord", label: "πŸ”‘ Wachtwoord" }, - ].map(({ id, label }) => ( - - ))} -
+ {/* ── Hoofdscherm ── */} + {view === "main" && ( + <> +
+ Mijn account + {closeBtn} +
- {/* Profiel tab */} - {activeTab === "profiel" && ( -
- {/* Avatar upload */} -
+ {/* Avatar + naam + email */} +
{avatar ? avatar : initials(currentUser?.naam) }
-
-
+ +
+ {actionRow(LOCK_ICON, "Wachtwoord wijzigen", () => setView("wachtwoord"))} +
+ {actionRow(LOGOUT_ICON, "Uitloggen", () => { logout(); onClose(); }, RED)} + + )} + + {/* ── Profiel bewerken ── */} + {view === "profiel" && ( + <> +
+ {backBtn()} + Profiel bewerken + {closeBtn} +
+
+ {/* Avatar β€” klikbaar om foto te uploaden */} +
+ - {avatar && ( - - )}
+ + {avatar && ( + + )} + { setNaam(e.target.value); setProfielOk(false); }} T={T} /> + { setEmail(e.target.value); setProfielOk(false); }} T={T} /> + {profielErr &&
{profielErr}
} + {profielOk &&
Profiel opgeslagen.
} +
-
- - { setNaam(e.target.value); setProfielSuccess(false); }} style={pIs} /> -
-
- - { setEmail(e.target.value); setProfielSuccess(false); }} style={pIs} /> -
- {profielError &&
{profielError}
} - {profielSuccess &&
Profiel opgeslagen.
} - - -
+ )} - {/* Wachtwoord tab */} - {activeTab === "wachtwoord" && ( -
- {[ - { label: "Huidig wachtwoord", value: huidig, set: setHuidig }, - { label: "Nieuw wachtwoord", value: nieuw, set: setNieuw }, - { label: "Bevestig nieuw", value: bevestig, set: setBevestig }, - ].map(({ label, value, set }) => ( -
- - { set(e.target.value); setPwSuccess(false); }} style={pIs} /> -
- ))} - {pwError &&
{pwError}
} - {pwSuccess &&
Wachtwoord gewijzigd.
} - -
+ {/* ── Wachtwoord wijzigen ── */} + {view === "wachtwoord" && ( + <> +
+ {backBtn()} + Wachtwoord wijzigen + {closeBtn} +
+
+ { setHuidig(e.target.value); setPwOk(false); }} T={T} /> + { setNieuw(e.target.value); setPwOk(false); }} T={T} /> + { setBevestig(e.target.value); setPwOk(false); }} T={T} /> + {pwErr &&
{pwErr}
} + {pwOk &&
Wachtwoord gewijzigd.
} + +
+ )} +
); diff --git a/frontend/src/context/AppContext.jsx b/frontend/src/context/AppContext.jsx index 2ad89e8..0c49b88 100644 --- a/frontend/src/context/AppContext.jsx +++ b/frontend/src/context/AppContext.jsx @@ -34,11 +34,13 @@ export function AppProvider({ children }) { }; // ── UI ──────────────────────────────────────────────────────── - const [darkMode, setDarkMode] = useState(true); - const [locked, setLocked] = useState(false); - const [tab, setTab] = useState(0); - const [menuOpen, setMenuOpen] = useState(false); - const [showUsers, setShowUsers] = useState(false); + const [darkMode, setDarkMode] = useState(true); + const [locked, setLocked] = useState(false); + const [tab, setTab] = useState(0); + const [menuOpen, setMenuOpen] = useState(false); + const [showUsers, setShowUsers] = useState(false); + const [showInstellingen, setShowInstellingen] = useState(false); + const [idleMinutes, setIdleMinutes] = useState(30); // ── FinanciΓ«le data ─────────────────────────────────────────── const [data, setDataRaw] = useState(INITIAL_DATA); @@ -201,6 +203,8 @@ export function AppProvider({ children }) { tab, setTab, menuOpen, setMenuOpen, showUsers, setShowUsers, + showInstellingen, setShowInstellingen, + idleMinutes, setIdleMinutes, T, data, setData, ev, schuld, cats, schuldBlokken, schuldHistory, diff --git a/package.json b/package.json index 5102ce5..082c73a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vibefinance", - "version": "0.1.4", + "version": "0.1.5-dev", "description": "VibeFinance β€” persoonlijk vermogensbeheer", "private": true, "repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance",