1
0
forked from maik/IPFS-portal
Files
IPFS-portal-deploy/IPFS-Portal-verbeterplan.html
maikrolf 99c113b6f5 fix: bw/stats 404, gateway links, history crash
- swr.ts: remove useBandwidth(), drop bw from useDashboard()
- events/route.ts: skip bandwidth SSE (Kubo 0.28.0 unsupported)
- usage/page.tsx: remove getBWStats call + bw references
- helpers.ts: gatewayLink -> ipfs.maos.dedyn.io (subdomain only)
- upload/page.tsx: fix local gatewayLink (subdomain only)
- storage.ts: update default gatewayUrl, add defensive getHistory()
2026-07-05 16:57:29 +02:00

1186 lines
48 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IPFS Portal — Verbeterplan</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0e17;
--surface-900: #111827;
--surface-800: #1a2332;
--surface-700: #233044;
--surface-500: #6b7a8f;
--surface-400: #94a3b8;
--surface-300: #cbd5e1;
--surface-200: #e2e8f0;
--brand-500: #14b8a6;
--brand-600: #0d9488;
--accent-cyan: #22d3ee;
--accent-purple: #a78bfa;
--accent-amber: #fbbf24;
--accent-green: #34d399;
--accent-rose: #fb7185;
--accent-blue: #60a5fa;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--surface-300);
line-height: 1.6;
padding: 0;
}
/* ── Layout ── */
.container { max-width: 960px; margin: 0 auto; padding: 2rem 1.5rem; }
/* ── Header ── */
.hero {
background: linear-gradient(135deg, #0d9488 0%, #0a0e17 50%, #1a2332 100%);
padding: 3rem 1.5rem;
text-align: center;
border-bottom: 1px solid var(--surface-700);
}
.hero h1 {
font-size: 2.5rem;
font-weight: 800;
color: #fff;
letter-spacing: -0.02em;
}
.hero .subtitle {
color: var(--surface-400);
margin-top: 0.5rem;
font-size: 1.05rem;
}
.hero .meta {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-top: 1rem;
font-size: 0.85rem;
color: var(--surface-500);
}
.hero .meta span { display: flex; align-items: center; gap: 0.35rem; }
/* ── Phase cards ── */
.phase {
background: var(--surface-900);
border: 1px solid var(--surface-700);
border-radius: 1rem;
margin: 2rem 0;
overflow: hidden;
}
.phase-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--surface-700);
}
.phase-badge {
font-size: 0.7rem;
font-weight: 700;
padding: 0.2rem 0.6rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.phase-badge.f1 { background: rgba(251, 113, 133, 0.15); color: var(--accent-rose); }
.phase-badge.f2 { background: rgba(251, 191, 36, 0.15); color: var(--accent-amber); }
.phase-badge.f3 { background: rgba(96, 165, 250, 0.15); color: var(--accent-blue); }
.phase-header h2 { font-size: 1.15rem; font-weight: 700; color: #fff; }
.phase-header .time {
margin-left: auto;
font-size: 0.8rem;
color: var(--surface-500);
background: var(--surface-800);
padding: 0.25rem 0.75rem;
border-radius: 999px;
}
.phase-body { padding: 1.5rem; }
/* ── Item ── */
.item {
border: 1px solid var(--surface-700);
border-radius: 0.75rem;
margin-bottom: 1.5rem;
overflow: hidden;
transition: border-color 0.2s;
}
.item:last-child { margin-bottom: 0; }
.item:hover { border-color: var(--brand-500/40); }
.item-header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: rgba(20, 184, 166, 0.03);
border-bottom: 1px solid var(--surface-700);
cursor: default;
}
.item-icon {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
flex-shrink: 0;
}
.item-icon.rose { background: rgba(251, 113, 133, 0.12); }
.item-icon.amber { background: rgba(251, 191, 36, 0.12); }
.item-icon.cyan { background: rgba(34, 211, 238, 0.12); }
.item-icon.purple{ background: rgba(167, 139, 250, 0.12); }
.item-icon.green { background: rgba(52, 211, 153, 0.12); }
.item-icon.blue { background: rgba(96, 165, 250, 0.12); }
.item-title {
flex: 1;
}
.item-title h3 { font-size: 1rem; font-weight: 600; color: #fff; }
.item-title .desc { font-size: 0.85rem; color: var(--surface-400); margin-top: 0.15rem; }
.item-priority {
font-size: 0.65rem;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
flex-shrink: 0;
}
.item-priority.critical { background: rgba(251, 113, 133, 0.15); color: var(--accent-rose); }
.item-priority.high { background: rgba(251, 191, 36, 0.15); color: var(--accent-amber); }
.item-priority.medium { background: rgba(52, 211, 153, 0.15); color: var(--accent-green); }
.item-priority.low { background: rgba(96, 165, 250, 0.15); color: var(--accent-blue); }
/* ── Item body (collapsible) ── */
.item-body { padding: 1.25rem; }
.item-body h4 {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--surface-500);
margin-bottom: 0.5rem;
}
.item-body .section { margin-bottom: 1.25rem; }
.item-body .section:last-child { margin-bottom: 0; }
/* ── What/Why/How grid ── */
.wh-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 600px) { .wh-grid { grid-template-columns: 1fr; } }
.wh-box {
background: var(--surface-800);
border: 1px solid var(--surface-700);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}
.wh-box .label {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--surface-500);
margin-bottom: 0.3rem;
}
.wh-box p { font-size: 0.85rem; color: var(--surface-300); }
.wh-box code { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; color: var(--accent-cyan); }
/* ── Flow diagram ── */
.flow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
background: var(--surface-800);
border: 1px solid var(--surface-700);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
margin: 0.5rem 0;
}
.flow .step {
background: var(--surface-700);
padding: 0.3rem 0.6rem;
border-radius: 0.3rem;
color: var(--surface-300);
white-space: nowrap;
}
.flow .arrow { color: var(--surface-500); font-size: 0.7rem; }
.flow .highlight { background: rgba(20, 184, 166, 0.15); color: var(--brand-500); }
/* ── Code blocks ── */
pre {
background: #050a14;
border: 1px solid var(--surface-700);
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
line-height: 1.5;
color: var(--surface-300);
}
pre .cm { color: var(--surface-500); font-style: italic; }
pre .kw { color: var(--accent-purple); }
pre .fn { color: var(--accent-cyan); }
pre .str { color: var(--accent-green); }
pre .ty { color: var(--accent-amber); }
pre .lit { color: var(--accent-rose); }
pre .cmt { color: var(--surface-500); }
/* ── Mini table ── */
.mini-table {
width: 100%;
border-collapse: collapse;
font-size: 0.83rem;
}
.mini-table th {
text-align: left;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--surface-500);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--surface-700);
}
.mini-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--surface-800);
color: var(--surface-300);
}
.mini-table tr:last-child td { border-bottom: none; }
.tag {
display: inline-block;
font-size: 0.65rem;
font-weight: 600;
padding: 0.1rem 0.45rem;
border-radius: 0.25rem;
background: var(--surface-700);
color: var(--surface-400);
margin-right: 0.25rem;
}
.tag.cyan { background: rgba(34, 211, 238, 0.12); color: var(--accent-cyan); }
.tag.green { background: rgba(52, 211, 153, 0.12); color: var(--accent-green);}
.tag.amber { background: rgba(251, 191, 36, 0.12); color: var(--accent-amber);}
.tag.rose { background: rgba(251, 113, 133, 0.12); color: var(--accent-rose); }
.tag.purple{ background: rgba(167, 139, 250, 0.12); color: var(--accent-purple);}
/* ── Architecture box ── */
.arch-box {
background: linear-gradient(135deg, var(--surface-800), var(--surface-900));
border: 1px solid var(--surface-700);
border-radius: 0.75rem;
padding: 1.25rem;
margin: 1rem 0;
}
.arch-box .arc-title {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--brand-500);
margin-bottom: 0.75rem;
}
/* ── Files list ── */
.file-list { list-style: none; }
.file-list li {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
color: var(--surface-400);
padding: 0.3rem 0;
}
.file-list li::before { content: '📄'; font-size: 0.75rem; }
.file-list li.new::before { content: '✨'; }
.file-list li.mod::before { content: '🔧'; }
</style>
</head>
<body>
<div class="hero">
<h1>IPFS Portal · Verbeterplan</h1>
<p class="subtitle">14 verbeteringen in 3 fasen — met flows, codevoorbeelden en architectuur</p>
<div class="meta">
<span>📦 IPFS Portal</span>
<span>⚡ Fase 1: ~2 dagen</span>
<span>🎯 14 items</span>
</div>
</div>
<div class="container">
<!-- ═══════════ Introd ═══════════ -->
<div class="arch-box">
<div class="arc-title">📋 Scope</div>
<p style="font-size:0.85rem;color:var(--surface-400);margin-bottom:0.75rem;">
Het IPFS Portal heeft stevige basis: 11 pagina's, Kubo proxy, smart contract integratie, upload/history systeem.
Maar mist gebruikersauth, rate limiting, notificaties, en veel UX polish. Dit plan pakt dat aan.
</p>
<table class="mini-table">
<tr><th>Fase</th><th>Focus</th><th>Items</th><th>Duur</th></tr>
<tr>
<td><span class="tag rose">Fase 1</span></td>
<td>Auth, limieten, notificaties</td>
<td>14</td>
<td>~2 dagen</td>
</tr>
<tr>
<td><span class="tag amber">Fase 2</span></td>
<td>UX verbeteringen</td>
<td>59</td>
<td>~1.5 dag</td>
</tr>
<tr>
<td><span class="tag cyan">Fase 3</span></td>
<td>Technische diepgang</td>
<td>1014</td>
<td>~2 dagen</td>
</tr>
</table>
</div>
<!-- ═══════════ FASE 1 ═══════════ -->
<div class="phase">
<div class="phase-header">
<span class="phase-badge f1">Fase 1</span>
<h2>Auth · Limieten · Notificaties</h2>
<span class="time">~2 dagen</span>
</div>
<div class="phase-body">
<!-- ── Item 1: Login ── -->
<div class="item">
<div class="item-header">
<div class="item-icon rose">🔐</div>
<div class="item-title">
<h3>1. Login / Sessie Management</h3>
<div class="desc">Inlogpagina + AuthContext + route bescherming</div>
</div>
<span class="item-priority critical">Critical</span>
</div>
<div class="item-body">
<div class="wh-grid">
<div class="wh-box">
<div class="label">Huidig</div>
<p>Geen login. Iedereen met de URL heeft volledige toegang tot uploaden, admin, users beheren.</p>
</div>
<div class="wh-box">
<div class="label">Doel</div>
<p>Alleen ingelogde gebruikers hebben toegang. Admin-rol voor user management.</p>
</div>
</div>
<div class="section">
<h4>Data flow</h4>
<div class="flow">
<span class="step">/login</span>
<span class="arrow"></span>
<span class="step">POST /api/auth/login<br>{username, password}</span>
<span class="arrow"></span>
<span class="step highlight">proxyUserAPI<br>/auth/login</span>
<span class="arrow"></span>
<span class="step">JWT token<br>→ cookie</span>
<span class="arrow"></span>
<span class="step">redirect /dashboard</span>
</div>
</div>
<div class="section">
<h4>Nieuwe bestanden</h4>
<ul class="file-list">
<li class="new">src/app/login/page.tsx — Login formulier met username/password</li>
<li class="new">src/lib/auth.tsx — AuthContext + AuthProvider + useAuth() hook</li>
<li class="new">src/app/register/page.tsx — Registratie formulier</li>
<li class="mod">src/app/layout.tsx — Wrap met AuthProvider</li>
<li class="mod">src/app/api/[...path]/route.ts — Voeg login/register/auth-check routes toe</li>
<li class="new">src/components/AuthGuard.tsx — Redirect niet-ingelogde users naar /login</li>
</ul>
</div>
<div class="section">
<h4>Code sketch — AuthContext</h4>
<pre><span class="cm">// src/lib/auth.tsx</span>
<span class="kw">import</span> { createContext, useContext, useState, useEffect } <span class="kw">from</span> <span class="str">'react'</span>;
<span class="kw">interface</span> <span class="ty">AuthState</span> {
user: <span class="ty">string</span> | <span class="kw">null</span>;
role: <span class="str">'admin'</span> | <span class="str">'user'</span> | <span class="kw">null</span>;
login: <span class="fn">(username: string, password: string) => Promise&lt;void&gt;</span>;
logout: <span class="fn">() => void</span>;
loading: <span class="ty">boolean</span>;
}
<span class="kw">export function</span> <span class="fn">AuthProvider</span>({ children }: { children: <span class="ty">React.ReactNode</span> }) {
<span class="kw">const</span> [user, setUser] = useState&lt;<span class="ty">string</span> | <span class="kw">null</span>&gt;(<span class="kw">null</span>);
<span class="kw">const</span> [loading, setLoading] = useState(<span class="lit">true</span>);
<span class="cm">// Check session on mount</span>
useEffect(() => {
fetch(<span class="str">'/api/auth/me'</span>)
.then(<span class="fn">r => r.ok ? r.json() : null</span>)
.then(<span class="fn">d => setUser(d?.username ?? null)</span>)
.finally(<span class="fn">() => setLoading(false)</span>);
}, []);
<span class="kw">async function</span> <span class="fn">login</span>(username: <span class="ty">string</span>, password: <span class="ty">string</span>) {
<span class="kw">const</span> res = await fetch(<span class="str">'/api/auth/login'</span>, {
method: <span class="str">'POST'</span>,
body: <span class="ty">JSON</span>.stringify({ username, password }),
});
<span class="kw">if</span> (!res.ok) <span class="kw">throw</span> <span class="kw">new</span> <span class="ty">Error</span>(<span class="str">'Login failed'</span>);
<span class="kw">const</span> data = await res.json();
setUser(data.username);
}
<span class="kw">return</span> (
&lt;AuthContext.Provider value={{ user, loading, login, logout: <span class="fn">() => { ... }</span> }}&gt;
{children}
&lt;/AuthContext.Provider&gt;
);
}</pre>
</div>
</div>
</div>
<!-- ── Item 2: Usage Limits ── -->
<div class="item">
<div class="item-header">
<div class="item-icon rose">📊</div>
<div class="item-title">
<h3>2. Usage Limits per User</h3>
<div class="desc">Echte opslaglimieten + free tier quota</div>
</div>
<span class="item-priority critical">Critical</span>
</div>
<div class="item-body">
<div class="wh-grid">
<div class="wh-box">
<div class="label">Huidig</div>
<p>Settings heeft <code>storageMax</code> maar zegt "Applied server-side" — er is geen server-side check. 1 user kan onbeperkt uploaden.</p>
</div>
<div class="wh-box">
<div class="label">Doel</div>
<p>Per user/wallet: max opslag (MB), max aantal uploads, free tier limiet. Check in API proxy voordat upload doorgaat.</p>
</div>
</div>
<div class="section">
<h4>Flow — upload met limietcheck</h4>
<div class="flow">
<span class="step">/api/files/upload</span>
<span class="arrow"></span>
<span class="step highlight">checkLimit(user)</span>
<span class="arrow"></span>
<span class="step step">over limit? → 429 Too Many</span>
<span class="arrow"></span>
<span class="step">OK → proxyIPFS</span>
<span class="arrow"></span>
<span class="step">Kubo add</span>
</div>
</div>
<div class="section">
<h4>Nieuwe bestanden</h4>
<ul class="file-list">
<li class="new">src/lib/limits.ts — Limiet configuratie + check functies</li>
<li class="mod">src/app/api/[...path]/route.ts — Injecteer limit check in proxyIPFS</li>
<li class="new">src/app/dashboard/components/UsageCard.tsx — Visuele usage meter</li>
</ul>
</div>
<div class="section">
<h4>Code sketch — limit checker</h4>
<pre><span class="cm">// src/lib/limits.ts</span>
<span class="kw">export interface</span> <span class="ty">UsageQuota</span> {
maxStorageMB: <span class="ty">number</span>; <span class="cm">// 30720 = 30 GB</span>
maxUploads: <span class="ty">number</span>; <span class="cm">// 1000</span>
freeUploadsPerDay: <span class="ty">number</span>; <span class="cm">// 50</span>
}
<span class="kw">const</span> <span class="ty">DEFAULT_QUOTA</span>: <span class="ty">UsageQuota</span> = {
maxStorageMB: <span class="lit">30720</span>,
maxUploads: <span class="lit">1000</span>,
freeUploadsPerDay: <span class="lit">50</span>,
};
<span class="kw">export async function</span> <span class="fn">checkUploadAllowed</span>(wallet?: <span class="ty">string</span>): <span class="ty">Promise</span>&lt;{
allowed: <span class="ty">boolean</span>;
reason?: <span class="ty">string</span>;
quota: <span class="ty">UsageQuota</span>;
used: { storageMB: <span class="ty">number</span>; uploads: <span class="ty">number</span> };
}&gt; {
<span class="cm">// Haal repo stats van Kubo</span>
<span class="cm">// Check storage, upload count, free tier</span>
<span class="cm">// Return: { allowed: false, reason: 'Storage limit reached (23/30 GB)' }</span>
}</pre>
</div>
</div>
</div>
<!-- ── Item 3: Notification System ── -->
<div class="item">
<div class="item-header">
<div class="item-icon rose">🔔</div>
<div class="item-title">
<h3>3. Notificatiesysteem</h3>
<div class="desc">Toast notifications voor uploads, errors, en acties</div>
</div>
<span class="item-priority critical">Critical</span>
</div>
<div class="item-body">
<div class="wh-grid">
<div class="wh-box">
<div class="label">Huidig</div>
<p>Upload starten = geen feedback tot het klaar is. Errors verdwijnen in console.</p>
</div>
<div class="wh-box">
<div class="label">Doel</div>
<p>Toast rechtsboven: "✅ Upload voltooid", "❌ Pin mislukt: …", "⚠️ Storage 90% vol"</p>
</div>
</div>
<div class="section">
<h4>Architectuur</h4>
<div class="flow">
<span class="step">Component</span>
<span class="arrow"></span>
<span class="step highlight">notify({ type, title })</span>
<span class="arrow"></span>
<span class="step">NotificationProvider</span>
<span class="arrow"></span>
<span class="step">Toast stack<br>(rechtsboven)</span>
<span class="arrow"></span>
<span class="step">Auto-dismiss<br>na 4s</span>
</div>
</div>
<div class="section">
<h4>Nieuwe bestanden</h4>
<ul class="file-list">
<li class="new">src/lib/notifications.tsx — NotificationContext + useNotify() hook</li>
<li class="new">src/components/Toast.tsx — Toast component met progress-bar</li>
<li class="new">src/components/ToastContainer.tsx — Gestapelde toasts, max 5</li>
<li class="mod">src/app/layout.tsx — Wrap met NotificationProvider</li>
</ul>
</div>
<div class="section">
<h4>Code sketch</h4>
<pre><span class="cm">// Gebruik in elke pagina:</span>
<span class="kw">const</span> { notify } = <span class="fn">useNotify</span>();
<span class="kw">async function</span> <span class="fn">handleUpload</span>(file: <span class="ty">File</span>) {
<span class="fn">notify</span>({ type: <span class="str">'info'</span>, title: <span class="str">'Uploaden…'</span>, message: file.name });
<span class="kw">try</span> {
<span class="kw">const</span> result = await <span class="fn">uploadFile</span>(file);
<span class="fn">notify</span>({ type: <span class="str">'success'</span>, title: <span class="str">'Upload voltooid'</span>, message: result.cid });
} <span class="kw">catch</span> (e) {
<span class="fn">notify</span>({ type: <span class="str">'error'</span>, title: <span class="str">'Upload mislukt'</span>, message: <span class="fn">shortError</span>(e) });
}
}</pre>
</div>
</div>
</div>
<!-- ── Item 4: User Usage Overview ── -->
<div class="item">
<div class="item-header">
<div class="item-icon rose">📊</div>
<div class="item-title">
<h3>4. User Usage Overview</h3>
<div class="desc">Persoonlijk dashboard met opslag, uploads, quota-status</div>
</div>
<span class="item-priority critical">Critical</span>
</div>
<div class="item-body">
<div class="wh-grid">
<div class="wh-box">
<div class="label">Huidig</div>
<p>Geen eigen overzicht. Stats staan verspreid over Dashboard (node-level), Users pagina (alleen admin), en Settings (alleen limiet tonen, geen verbruik).</p>
</div>
<div class="wh-box">
<div class="label">Doel</div>
<p>Elke ingelogde user ziet in één oogopslag: storage gebruikt/limiet, aantal uploads, free quota resterend, bandwidth, pinned CIDs.</p>
</div>
</div>
<div class="section">
<h4>Component structuur</h4>
<div class="flow" style="flex-wrap:nowrap;overflow-x:auto;">
<span class="step">UsageOverview</span>
<span class="arrow">├─</span>
<span class="step highlight">StorageGauge</span>
<span class="arrow">├─</span>
<span class="step highlight">UploadCountCard</span>
<span class="arrow">├─</span>
<span class="step highlight">FreeTierProgress</span>
<span class="arrow">├─</span>
<span class="step highlight">BandwidthMini</span>
<span class="arrow">└─</span>
<span class="step highlight">RecentUploads</span>
</div>
</div>
<div class="section">
<h4>Data sources</h4>
<table class="mini-table">
<tr><th>Data</th><th>Bron</th><th>API call</th></tr>
<tr><td>Storage used / max</td><td>Kubo repo stat</td><td><code>/api/repo/stats</code></td></tr>
<tr><td>Uploads count</td><td>Contract + localStorage</td><td><code>getUserUploads()</code> + <code>getHistory()</code></td></tr>
<tr><td>Free quota remaining</td><td>Contract</td><td><code>paymentService.getUserUploads()</code></td></tr>
<tr><td>Bandwidth in/out</td><td>Kubo bw stat</td><td><code>/api/bw/stats</code></td></tr>
<tr><td>Pinned CIDs</td><td>Kubo pin ls</td><td><code>/api/pins</code></td></tr>
<tr><td>Peer count</td><td>Kubo swarm peers</td><td><code>/api/peers</code></td></tr>
</table>
</div>
<div class="section">
<h4>Nieuwe bestanden</h4>
<ul class="file-list">
<li class="new">src/app/usage/page.tsx — Volledige usage overview pagina</li>
<li class="new">src/app/dashboard/components/StorageGauge.tsx — Ring chart met used/max</li>
<li class="new">src/app/dashboard/components/FreeTierProgress.tsx — Progress bar free uploads</li>
<li class="mod">src/components/PortalSidebar.tsx — Voeg "Usage" link toe</li>
</ul>
</div>
<div class="section">
<h4>Code sketch — StorageGauge</h4>
<pre><span class="cm">// src/app/dashboard/components/StorageGauge.tsx</span>
<span class="kw">export function</span> <span class="fn">StorageGauge</span>({ used, max }: { used: <span class="ty">number</span>; max: <span class="ty">number</span> }) {
<span class="kw">const</span> pct = max > <span class="lit">0</span> ? Math.min(used / max, <span class="lit">1</span>) : <span class="lit">0</span>;
<span class="kw">const</span> color = pct > <span class="lit">0.9</span> ? <span class="str">'text-accent-rose'</span>
: pct > <span class="lit">0.7</span> ? <span class="str">'text-accent-amber'</span>
: <span class="str">'text-accent-cyan'</span>;
<span class="kw">return</span> (
&lt;div className="glass rounded-xl p-5"&gt;
&lt;div className="flex items-center justify-between mb-2"&gt;
&lt;span className="text-xs text-surface-400"&gt;Storage&lt;/span&gt;
&lt;span className={"text-xs font-mono " + color}&gt;{used.toFixed(1)} / {max.toFixed(1)} GB&lt;/span&gt;
&lt;/div&gt;
&lt;div className="h-2 rounded-full bg-surface-700 overflow-hidden"&gt;
&lt;div className={"h-full rounded-full transition-all duration-500 " + (
pct > <span class="lit">0.9</span> ? <span class="str">'bg-accent-rose'</span>
: pct > <span class="lit">0.7</span> ? <span class="str">'bg-accent-amber'</span>
: <span class="str">'bg-accent-cyan'</span>
)} style={{ width: `${pct * <span class="lit">100</span>}%` }} /&gt;
&lt;/div&gt;
&lt;/div&gt;
);
}</pre>
</div>
</div>
</div>
<!-- ── Item 5: Self Service ── -->
<div class="item">
<div class="item-header">
<div class="item-icon rose">👤</div>
<div class="item-title">
<h3>5. User Self-Service</h3>
<div class="desc">Registratie, wachtwoord wijzigen, profiel</div>
</div>
<span class="item-priority high">High</span>
</div>
<div class="item-body">
<div class="wh-grid">
<div class="wh-box">
<div class="label">Huidig</div>
<p>Alleen admin kan users aanmaken. Geen registratie of wachtwoord resetten.</p>
</div>
<div class="wh-box">
<div class="label">Doel</div>
<p>Zelf registreren, wachtwoord wijzigen, gebruikersprofiel met upload stats.</p>
</div>
</div>
<div class="section">
<h4>Nieuwe bestanden</h4>
<ul class="file-list">
<li class="new">src/app/profile/page.tsx — Wachtwoord wijzigen, API key overzicht</li>
<li class="new">src/app/register/page.tsx — Registratie met username/password</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════ FASE 2 ═══════════ -->
<div class="phase">
<div class="phase-header">
<span class="phase-badge f2">Fase 2</span>
<h2>UX Verbeteringen</h2>
<span class="time">~1.5 dag</span>
</div>
<div class="phase-body">
<!-- ── Item 5: Batch Operations ── -->
<div class="item">
<div class="item-header">
<div class="item-icon amber">📑</div>
<div class="item-title">
<h3>5. Batch Operaties</h3>
<div class="desc">Meerdere pins/history items selecteren + bulk acties</div>
</div>
<span class="item-priority high">High</span>
</div>
<div class="item-body">
<div class="section">
<h4>Flow — batch unpin</h4>
<div class="flow">
<span class="step">Checkbox per pin</span>
<span class="arrow"></span>
<span class="step highlight">"2 selected" bar</span>
<span class="arrow"></span>
<span class="step">Knop "Unpin selected"</span>
<span class="arrow"></span>
<span class="step">Confirm dialog</span>
<span class="arrow"></span>
<span class="step">DELETE /api/pins/:cid<br>(parallel)</span>
<span class="arrow"></span>
<span class="step">Notify result</span>
</div>
</div>
<div class="section">
<h4>Bestanden</h4>
<ul class="file-list">
<li class="new">src/components/BatchBar.tsx — "N selected" floating bar</li>
<li class="mod">src/app/pins/page.tsx — Checkbox in elke rij + BatchBar</li>
<li class="mod">src/app/history/page.tsx — Idem voor history clear</li>
</ul>
</div>
</div>
</div>
<!-- ── Item 6: Dark/Light Mode ── -->
<div class="item">
<div class="item-header">
<div class="item-icon amber">🌓</div>
<div class="item-title">
<h3>6. Dark/Light Mode (live)</h3>
<div class="desc">Theme switcher die direct werkt, niet pas bij nieuwe sessie</div>
</div>
<span class="item-priority high">High</span>
</div>
<div class="item-body">
<div class="wh-grid">
<div class="wh-box">
<div class="label">Huidig</div>
<p><code>"Theme preference is saved but only affects new sessions. Full theme switching coming soon."</code></p>
</div>
<div class="wh-box">
<div class="label">Doel</div>
<p>Toggle in sidebar → direct alle CSS variablen naar light mode. CSS transition voor vloeiende overgang.</p>
</div>
</div>
<div class="section">
<h4>Bestanden</h4>
<ul class="file-list">
<li class="new">src/lib/theme.tsx — ThemeContext + useTheme() hook</li>
<li class="mod">src/app/layout.tsx — <code>.dark</code> / <code>.light</code> class op html element</li>
<li class="mod">src/app/globals.css — Voeg <code>:root.light</code> variablen toe</li>
<li class="mod">src/components/PortalSidebar.tsx — Theme toggle button</li>
</ul>
</div>
<div class="section">
<h4>Code sketch — ThemeContext</h4>
<pre><span class="cm">// src/lib/theme.tsx</span>
<span class="kw">export function</span> <span class="fn">ThemeProvider</span>({ children }) {
<span class="kw">const</span> [theme, setTheme] = useState&lt;<span class="str">'dark'</span> | <span class="str">'light'</span>&gt;(<span class="fn">getSettings</span>().theme);
useEffect(() => {
document.documentElement.classList.toggle(<span class="str">'dark'</span>, theme === <span class="str">'dark'</span>);
document.documentElement.classList.toggle(<span class="str">'light'</span>, theme === <span class="str">'light'</span>);
}, [theme]);
<span class="kw">const</span> toggle = <span class="fn">() => {
const next = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
saveSettings({ theme: next });
}</span>;
}</pre>
<p style="margin-top:0.5rem;font-size:0.8rem;color:var(--surface-400);">
CSS: <code>:root.light { --bg: #fff; --surface-900: #f8fafc; ... }</code> +
<code>* { transition: background-color 0.3s, border-color 0.3s; }</code>
</p>
</div>
</div>
</div>
<!-- ── Item 7: Skeleton Loaders ── -->
<div class="item">
<div class="item-header">
<div class="item-icon amber">💀</div>
<div class="item-title">
<h3>7. Skeleton Loaders</div>
<div class="desc">Vervang "Loading…" text door skeletanimaties</div>
</div>
<span class="item-priority medium">Medium</span>
</div>
<div class="item-body">
<div class="section">
<h4>Voorbeelden</h4>
<table class="mini-table">
<tr><th>Pagina</th><th>Huidig</th><th>Skeleton</th></tr>
<tr><td>Dashboard</td><td>— (data komt leeg binnen)</td><td>4 pulsende card outlines</td></tr>
<tr><td>Peers</td><td>"Loading…"</td><td>10 rij skeletten</td></tr>
<tr><td>Pins</td><td>"Loading…"</td><td>Lijst skeletten met icon + text</td></tr>
<tr><td>Users</td><td>"Loading…"</td><td>Tabel skeletten</td></tr>
</table>
</div>
<div class="section">
<h4>Bestanden</h4>
<ul class="file-list">
<li class="new">src/components/Skeleton.tsx — <code>&lt;Skeleton className="h-4 w-full" /&gt;</code></li>
<li class="mod">src/app/dashboard/page.tsx — Skelet voor stat cards + peer lijst</li>
<li class="mod">src/app/peers/page.tsx — Skelet voor tabel</li>
<li class="mod">src/app/pins/page.tsx — Skelet voor pin lijst</li>
<li class="mod">src/app/users/page.tsx — Skelet voor user tabel</li>
</ul>
</div>
<div class="section">
<h4>Code sketch</h4>
<pre><span class="cm">// src/components/Skeleton.tsx</span>
<span class="kw">export function</span> <span class="fn">Skeleton</span>({ className }: { className?: <span class="ty">string</span> }) {
<span class="kw">return</span> (
&lt;div
className={`animate-pulse rounded-md bg-surface-800 ${className ?? ''}`}
style={{ animationDuration: <span class="str">'1.5s'</span> }}
/&gt;
);
}
<span class="cm">// Gebruik in pins pagina:</span>
{loading && (
&lt;div className="divide-y divide-surface-800"&gt;
{Array.from({ length: <span class="lit">6</span> }).map((_, i) => (
&lt;div key={i} className="flex items-center gap-3 px-5 py-3"&gt;
&lt;Skeleton className="w-4 h-4 rounded" /&gt;
&lt;Skeleton className="h-4 w-48" /&gt;
&lt;Skeleton className="h-3 w-16 ml-auto" /&gt;
&lt;/div&gt;
))}
&lt;/div&gt;
)}</pre>
</div>
</div>
</div>
<!-- ── Item 8: Error Boundary ── -->
<div class="item">
<div class="item-header">
<div class="item-icon amber">🛡️</div>
<div class="item-title">
<h3>8. Error Boundary</h3>
<div class="desc">Voorkom lege pagina bij component crash</div>
</div>
<span class="item-priority medium">Medium</span>
</div>
<div class="item-body">
<div class="section">
<h4>Bestanden</h4>
<ul class="file-list">
<li class="new">src/components/ErrorBoundary.tsx — Class component met componentDidCatch</li>
<li class="new">src/app/error.tsx — Next.js error boundary (al ondersteund, alleen invullen)</li>
</ul>
</div>
<div class="section">
<h4>Code sketch</h4>
<pre><span class="cm">// src/components/ErrorBoundary.tsx</span>
<span class="kw">export class</span> <span class="ty">ErrorBoundary</span> <span class="kw">extends</span> React.Component&lt;{ fallback?: ReactNode }, { error: Error | null }&gt; {
state = { error: <span class="kw">null</span> };
<span class="fn">static getDerivedStateFromError</span>(error: <span class="ty">Error</span>) { <span class="kw">return</span> { error }; }
render() {
<span class="kw">if</span> (this.state.error) {
<span class="kw">return</span> (
&lt;div className="glass rounded-xl p-8 text-center"&gt;
&lt;AlertCircle className="w-8 h-8 text-accent-rose mx-auto mb-3" /&gt;
&lt;h3 className="text-surface-200 font-semibold mb-1"&gt;Er ging iets mis&lt;/h3&gt;
&lt;p className="text-xs text-surface-500 mb-4"&gt;{this.state.error.message}&lt;/p&gt;
&lt;button onClick={() => <span class="fn">this.setState</span>({ error: <span class="kw">null</span> })}
className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm"&gt;
Probeer opnieuw
&lt;/button&gt;
&lt;/div&gt;
);
}
<span class="kw">return</span> this.props.children;
}
}</pre>
</div>
</div>
</div>
<!-- ── Item 9: Downloads ── -->
<div class="item">
<div class="item-header">
<div class="item-icon amber">⬇️</div>
<div class="item-title">
<h3>9. Bestanden Downloaden</h3>
<div class="desc">Download knop in explorer + history i.p.v. alleen link kopiëren</div>
</div>
<span class="item-priority medium">Medium</span>
</div>
<div class="item-body">
<div class="section">
<h4>Flow</h4>
<div class="flow">
<span class="step">Download knop</span>
<span class="arrow"></span>
<span class="step highlight">fetch /api/explorer/cat/{cid}</span>
<span class="arrow"></span>
<span class="step">Blob → URL.createObjectURL</span>
<span class="arrow"></span>
<span class="step">&lt;a download="..." href={url}&gt; klik&lt;/a&gt;</span>
</div>
</div>
<div class="section">
<h4>Bestanden</h4>
<ul class="file-list">
<li class="new">src/lib/download.ts — downloadFile(cid, filename) utility</li>
<li class="mod">src/app/explorer/components/DirectoryListing.tsx — Download knop per file</li>
<li class="mod">src/app/history/page.tsx — Download knop in acties</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════ FASE 3 ═══════════ -->
<div class="phase">
<div class="phase-header">
<span class="phase-badge f3">Fase 3</span>
<h2>Technische Diepgang</h2>
<span class="time">~2 dagen</span>
</div>
<div class="phase-body">
<!-- ── Item 10: PWA ── -->
<div class="item">
<div class="item-header">
<div class="item-icon blue">📱</div>
<div class="item-title">
<h3>10. PWA (Progressive Web App)</h3>
<div class="desc">Installable als app, offline ondersteuning, manifest</div>
</div>
<span class="item-priority low">Low</span>
</div>
<div class="item-body">
<div class="section">
<h4>Bestanden</h4>
<ul class="file-list">
<li class="new">public/manifest.json — Naam, iconen, theme_color</li>
<li class="new">public/sw.js — Service worker voor offline cache</li>
<li class="new">public/icon-192.png, icon-512.png — App iconen</li>
<li class="mod">src/app/layout.tsx — Voeg manifest link + meta theme-color toe</li>
</ul>
</div>
</div>
</div>
<!-- ── Item 11: Tests ── -->
<div class="item">
<div class="item-header">
<div class="item-icon blue">🧪</div>
<div class="item-title">
<h3>11. Tests</h3>
<div class="desc">Van 0 naar gedekt — unit + integratie</div>
</div>
<span class="item-priority low">Low</span>
</div>
<div class="item-body">
<div class="section">
<h4>Wat testen</h4>
<table class="mini-table">
<tr><th>Laag</th><th>Wat</th><th>Tool</th></tr>
<tr><td>Unit</td><td><code>helpers.ts</code> — formatBytes, gatewayLink, truncateCid</td><td>Vitest</td></tr>
<tr><td>Unit</td><td><code>wallet.ts</code> — formatWeiToETH, parseEther</td><td>Vitest</td></tr>
<tr><td>Unit</td><td><code>limits.ts</code> — checkUploadAllowed</td><td>Vitest</td></tr>
<tr><td>Integratie</td><td>Catch-all route — matchRoute, mapToKuboAPI</td><td>Vitest + MSW</td></tr>
<tr><td>E2E</td><td>Dashboard laadt, peers tonen, upload flow</td><td>Playwright</td></tr>
</table>
</div>
<div class="section">
<h4>Bestanden</h4>
<ul class="file-list">
<li class="new">src/lib/__tests__/helpers.test.ts</li>
<li class="new">src/lib/__tests__/wallet.test.ts</li>
<li class="new">src/app/api/__tests__/route.test.ts</li>
<li class="new">vitest.config.ts</li>
<li class="new">e2e/dashboard.spec.ts</li>
</ul>
</div>
</div>
</div>
<!-- ── Item 12: API Caching ── -->
<div class="item">
<div class="item-header">
<div class="item-icon blue"></div>
<div class="item-title">
<h3>12. API Caching / SWR</h3>
<div class="desc">Vervang eigen polling door SWR of React Query</div>
</div>
<span class="item-priority low">Low</span>
</div>
<div class="item-body">
<div class="section">
<h4>Huidig vs Nieuw</h4>
<pre><span class="cm">// HUIDIG — manual fetch + interval</span>
useEffect(() => {
<span class="fn">load</span>(); <span class="cm">// direct op mount</span>
<span class="kw">const</span> iv = setInterval(<span class="fn">load</span>, <span class="lit">15000</span>); <span class="cm">// elke 15s opnieuw</span>
<span class="kw">return</span> () => clearInterval(iv);
}, []);
<span class="cm">// NIEUW — SWR (stale-while-revalidate)</span>
<span class="kw">const</span> { data: peers } = <span class="fn">useSWR</span>(<span class="str">'/api/peers'</span>, fetch, {
refreshInterval: <span class="lit">15000</span>, <span class="cm">// zelfde interval</span>
revalidateOnFocus: <span class="lit">true</span>, <span class="cm">// + op tab focus</span>
dedupingInterval: <span class="lit">5000</span>, <span class="cm">// geen duplicaten</span>
});</pre>
</div>
<div class="section">
<h4>Voordelen</h4>
<ul style="font-size:0.85rem;color:var(--surface-400);list-style:disc;padding-left:1.25rem;">
<li>Automatische cache + deduplicatie</li>
<li>Optimistic UI mogelijk</li>
<li>Minder code per pagina</li>
<li>Global cache key management</li>
</ul>
</div>
</div>
</div>
<!-- ── Item 13: WebSocket ── -->
<div class="item">
<div class="item-header">
<div class="item-icon blue">🔌</div>
<div class="item-title">
<h3>13. WebSocket Live Updates</h3>
<div class="desc">Bandwidth + peers live zonder te pollen</div>
</div>
<span class="item-priority low">Low</span>
</div>
<div class="item-body">
<div class="section">
<h4>Architectuur</h4>
<div class="flow">
<span class="step">Next.js server</span>
<span class="arrow"></span>
<span class="step">WS endpoint<br>/api/ws</span>
<span class="arrow"></span>
<span class="step">Pushed updates<br>(peer join/leave, bw)</span>
<span class="arrow"></span>
<span class="step">Dashboard<br>live update</span>
</div>
<p style="margin-top:0.5rem;font-size:0.8rem;color:var(--surface-500);">
Let op: Next.js edge runtime ondersteunt WebSocket niet native. Opties: aparte WS server (Node.js), of
Server-Sent Events via route handler. SSE is eenvoudiger en werkt door bestaande proxy heen.
</p>
</div>
</div>
</div>
<!-- ── Item 14: IPFS Search ── -->
<div class="item">
<div class="item-header">
<div class="item-icon blue">🔍</div>
<div class="item-title">
<h3>14. Full-text Search in IPFS</h3>
<div class="desc">Doorzoek gepinde IPFS content op tekst</div>
</div>
<span class="item-priority low">Low</span>
</div>
<div class="item-body">
<div class="section">
<h4>Concept</h4>
<ul style="font-size:0.85rem;color:var(--surface-400);list-style:disc;padding-left:1.25rem;">
<li>Service die periodic alle gepinde files indexeert</li>
<li>Text extraction uit .txt, .md, .json, .html</li>
<li>Mini search index in IndexedDB of localStorage</li>
<li>Zoekveld in explorer + dashboard</li>
<li><strong>Beperking:</strong> alleen tekstuele content, geen images/binary</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════ SAMENVATTING ═══════════ -->
<div class="phase">
<div class="phase-header">
<span class="phase-badge f1" style="background:rgba(20,184,166,0.12);color:var(--brand-500);">Totaal</span>
<h2>Overzicht alle bestanden</h2>
<span class="time">15 items</span>
</div>
<div class="phase-body">
<table class="mini-table">
<tr><th>#</th><th>Item</th><th>Priority</th><th>Fase</th><th>Nieuwe bestanden</th><th>Wijzigingen</th></tr>
<tr><td>1</td><td>Login / Sessie</td><td><span class="tag rose">Critical</span></td><td>F1</td><td>4</td><td>2</td></tr>
<tr><td>2</td><td>Usage Limits</td><td><span class="tag rose">Critical</span></td><td>F1</td><td>2</td><td>1</td></tr>
<tr><td>3</td><td>Notificaties</td><td><span class="tag rose">Critical</span></td><td>F1</td><td>3</td><td>1</td></tr>
<tr><td>4</td><td>User Usage Overview</td><td><span class="tag rose">Critical</span></td><td>F1</td><td>3</td><td>1</td></tr>
<tr><td>5</td><td>Self-Service</td><td><span class="tag amber">High</span></td><td>F1</td><td>2</td><td>0</td></tr>
<tr><td>6</td><td>Batch Operaties</td><td><span class="tag amber">High</span></td><td>F2</td><td>1</td><td>2</td></tr>
<tr><td>7</td><td>Dark/Light Mode</td><td><span class="tag amber">High</span></td><td>F2</td><td>1</td><td>3</td></tr>
<tr><td>8</td><td>Skeleton Loaders</td><td><span class="tag green">Medium</span></td><td>F2</td><td>1</td><td>4</td></tr>
<tr><td>9</td><td>Error Boundary</td><td><span class="tag green">Medium</span></td><td>F2</td><td>2</td><td>0</td></tr>
<tr><td>10</td><td>Downloads</td><td><span class="tag green">Medium</span></td><td>F2</td><td>1</td><td>2</td></tr>
<tr><td>11</td><td>PWA</td><td><span class="tag cyan">Low</span></td><td>F3</td><td>4</td><td>1</td></tr>
<tr><td>12</td><td>Tests</td><td><span class="tag cyan">Low</span></td><td>F3</td><td>4</td><td>0</td></tr>
<tr><td>13</td><td>API Caching</td><td><span class="tag cyan">Low</span></td><td>F3</td><td>0</td><td>5</td></tr>
<tr><td>14</td><td>WebSocket/SSE</td><td><span class="tag cyan">Low</span></td><td>F3</td><td>2</td><td>1</td></tr>
<tr><td>15</td><td>IPFS Search</td><td><span class="tag cyan">Low</span></td><td>F3</td><td>2</td><td>1</td></tr>
</table>
<div style="margin-top:1rem;display:flex;gap:1rem;flex-wrap:wrap;">
<div style="flex:1;min-width:120px;background:var(--surface-800);border-radius:0.5rem;padding:1rem;text-align:center;">
<div style="font-size:1.5rem;font-weight:800;color:var(--accent-cyan);">32</div>
<div style="font-size:0.75rem;color:var(--surface-500);">Nieuwe bestanden</div>
</div>
<div style="flex:1;min-width:120px;background:var(--surface-800);border-radius:0.5rem;padding:1rem;text-align:center;">
<div style="font-size:1.5rem;font-weight:800;color:var(--accent-amber);">24</div>
<div style="font-size:0.75rem;color:var(--surface-500);">Wijzigingen</div>
</div>
<div style="flex:1;min-width:120px;background:var(--surface-800);border-radius:0.5rem;padding:1rem;text-align:center;">
<div style="font-size:1.5rem;font-weight:800;color:var(--accent-rose);">4</div>
<div style="font-size:0.75rem;color:var(--surface-500);">Critical items</div>
</div>
<div style="flex:1;min-width:120px;background:var(--surface-800);border-radius:0.5rem;padding:1rem;text-align:center;">
<div style="font-size:1.5rem;font-weight:800;color:var(--accent-green);">~6</div>
<div style="font-size:0.75rem;color:var(--surface-500);">Dagen totaal</div>
</div>
</div>
</div>
</div>
<!-- ═══════════ VOORSTEL ═══════════ -->
<div class="arch-box" style="border-color:var(--brand-500/30);">
<div class="arc-title">💬 Mijn advies</div>
<p style="font-size:0.85rem;color:var(--surface-300);margin-bottom:0.75rem;">
<strong>Begin met Fase 1</strong> — dat zijn de 5 items die het verschil maken tussen "demo" en "productie":
</p>
<ol style="font-size:0.85rem;color:var(--surface-400);padding-left:1.25rem;display:flex;flex-direction:column;gap:0.3rem;">
<li>🔐 <strong>Login</strong> — zonder auth is alles open</li>
<li>📊 <strong>Usage limits</strong> — voorkom dat 1 user de hele schijf vult</li>
<li>🔔 <strong>Notificaties</strong> — gebruikers zien wat er gebeurt</li>
<li>📈 <strong>Usage Overview</strong> — inzicht in eigen verbruik</li>
<li>👤 <strong>Self-service</strong> — registratie + profiel</li>
</ol>
<p style="margin-top:0.75rem;font-size:0.85rem;color:var(--brand-500);">
→ Zeg het woord en ik maak er een uitvoerbaar plan van met todo's.
</p>
</div>
</div><!-- /container -->
</body>
</html>