Files
IPFS-portal/IPFS-Portal-verbeterplan.html
T

1186 lines
48 KiB
HTML
Raw Normal View History

<!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>