1186 lines
48 KiB
HTML
1186 lines
48 KiB
HTML
|
|
<!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>1–4</td>
|
|||
|
|
<td>~2 dagen</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td><span class="tag amber">Fase 2</span></td>
|
|||
|
|
<td>UX verbeteringen</td>
|
|||
|
|
<td>5–9</td>
|
|||
|
|
<td>~1.5 dag</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td><span class="tag cyan">Fase 3</span></td>
|
|||
|
|
<td>Technische diepgang</td>
|
|||
|
|
<td>10–14</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<void></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<<span class="ty">string</span> | <span class="kw">null</span>>(<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> (
|
|||
|
|
<AuthContext.Provider value={{ user, loading, login, logout: <span class="fn">() => { ... }</span> }}>
|
|||
|
|
{children}
|
|||
|
|
</AuthContext.Provider>
|
|||
|
|
);
|
|||
|
|
}</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><{
|
|||
|
|
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> };
|
|||
|
|
}> {
|
|||
|
|
<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> (
|
|||
|
|
<div className="glass rounded-xl p-5">
|
|||
|
|
<div className="flex items-center justify-between mb-2">
|
|||
|
|
<span className="text-xs text-surface-400">Storage</span>
|
|||
|
|
<span className={"text-xs font-mono " + color}>{used.toFixed(1)} / {max.toFixed(1)} GB</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="h-2 rounded-full bg-surface-700 overflow-hidden">
|
|||
|
|
<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>}%` }} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}</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<<span class="str">'dark'</span> | <span class="str">'light'</span>>(<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><Skeleton className="h-4 w-full" /></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> (
|
|||
|
|
<div
|
|||
|
|
className={`animate-pulse rounded-md bg-surface-800 ${className ?? ''}`}
|
|||
|
|
style={{ animationDuration: <span class="str">'1.5s'</span> }}
|
|||
|
|
/>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
<span class="cm">// Gebruik in pins pagina:</span>
|
|||
|
|
{loading && (
|
|||
|
|
<div className="divide-y divide-surface-800">
|
|||
|
|
{Array.from({ length: <span class="lit">6</span> }).map((_, i) => (
|
|||
|
|
<div key={i} className="flex items-center gap-3 px-5 py-3">
|
|||
|
|
<Skeleton className="w-4 h-4 rounded" />
|
|||
|
|
<Skeleton className="h-4 w-48" />
|
|||
|
|
<Skeleton className="h-3 w-16 ml-auto" />
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}</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<{ fallback?: ReactNode }, { error: Error | null }> {
|
|||
|
|
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> (
|
|||
|
|
<div className="glass rounded-xl p-8 text-center">
|
|||
|
|
<AlertCircle className="w-8 h-8 text-accent-rose mx-auto mb-3" />
|
|||
|
|
<h3 className="text-surface-200 font-semibold mb-1">Er ging iets mis</h3>
|
|||
|
|
<p className="text-xs text-surface-500 mb-4">{this.state.error.message}</p>
|
|||
|
|
<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">
|
|||
|
|
Probeer opnieuw
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
<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"><a download="..." href={url}> klik</a></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>
|