14 verbeteringen in 3 fasen — met flows, codevoorbeelden en architectuur
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.
| Fase | Focus | Items | Duur |
|---|---|---|---|
| Fase 1 | Auth, limieten, notificaties | 1–4 | ~2 dagen |
| Fase 2 | UX verbeteringen | 5–9 | ~1.5 dag |
| Fase 3 | Technische diepgang | 10–14 | ~2 dagen |
Geen login. Iedereen met de URL heeft volledige toegang tot uploaden, admin, users beheren.
Alleen ingelogde gebruikers hebben toegang. Admin-rol voor user management.
// src/lib/auth.tsx import { createContext, useContext, useState, useEffect } from 'react'; interface AuthState { user: string | null; role: 'admin' | 'user' | null; login: (username: string, password: string) => Promise<void>; logout: () => void; loading: boolean; } export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<string | null>(null); const [loading, setLoading] = useState(true); // Check session on mount useEffect(() => { fetch('/api/auth/me') .then(r => r.ok ? r.json() : null) .then(d => setUser(d?.username ?? null)) .finally(() => setLoading(false)); }, []); async function login(username: string, password: string) { const res = await fetch('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }), }); if (!res.ok) throw new Error('Login failed'); const data = await res.json(); setUser(data.username); } return ( <AuthContext.Provider value={{ user, loading, login, logout: () => { ... } }}> {children} </AuthContext.Provider> ); }
Settings heeft storageMax maar zegt "Applied server-side" — er is geen server-side check. 1 user kan onbeperkt uploaden.
Per user/wallet: max opslag (MB), max aantal uploads, free tier limiet. Check in API proxy voordat upload doorgaat.
// src/lib/limits.ts export interface UsageQuota { maxStorageMB: number; // 30720 = 30 GB maxUploads: number; // 1000 freeUploadsPerDay: number; // 50 } const DEFAULT_QUOTA: UsageQuota = { maxStorageMB: 30720, maxUploads: 1000, freeUploadsPerDay: 50, }; export async function checkUploadAllowed(wallet?: string): Promise<{ allowed: boolean; reason?: string; quota: UsageQuota; used: { storageMB: number; uploads: number }; }> { // Haal repo stats van Kubo // Check storage, upload count, free tier // Return: { allowed: false, reason: 'Storage limit reached (23/30 GB)' } }
Upload starten = geen feedback tot het klaar is. Errors verdwijnen in console.
Toast rechtsboven: "✅ Upload voltooid", "❌ Pin mislukt: …", "⚠️ Storage 90% vol"
// Gebruik in elke pagina: const { notify } = useNotify(); async function handleUpload(file: File) { notify({ type: 'info', title: 'Uploaden…', message: file.name }); try { const result = await uploadFile(file); notify({ type: 'success', title: 'Upload voltooid', message: result.cid }); } catch (e) { notify({ type: 'error', title: 'Upload mislukt', message: shortError(e) }); } }
Geen eigen overzicht. Stats staan verspreid over Dashboard (node-level), Users pagina (alleen admin), en Settings (alleen limiet tonen, geen verbruik).
Elke ingelogde user ziet in één oogopslag: storage gebruikt/limiet, aantal uploads, free quota resterend, bandwidth, pinned CIDs.
| Data | Bron | API call |
|---|---|---|
| Storage used / max | Kubo repo stat | /api/repo/stats |
| Uploads count | Contract + localStorage | getUserUploads() + getHistory() |
| Free quota remaining | Contract | paymentService.getUserUploads() |
| Bandwidth in/out | Kubo bw stat | /api/bw/stats |
| Pinned CIDs | Kubo pin ls | /api/pins |
| Peer count | Kubo swarm peers | /api/peers |
// src/app/dashboard/components/StorageGauge.tsx export function StorageGauge({ used, max }: { used: number; max: number }) { const pct = max > 0 ? Math.min(used / max, 1) : 0; const color = pct > 0.9 ? 'text-accent-rose' : pct > 0.7 ? 'text-accent-amber' : 'text-accent-cyan'; return ( <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 > 0.9 ? 'bg-accent-rose' : pct > 0.7 ? 'bg-accent-amber' : 'bg-accent-cyan' )} style={{ width: `${pct * 100}%` }} /> </div> </div> ); }
Alleen admin kan users aanmaken. Geen registratie of wachtwoord resetten.
Zelf registreren, wachtwoord wijzigen, gebruikersprofiel met upload stats.
"Theme preference is saved but only affects new sessions. Full theme switching coming soon."
Toggle in sidebar → direct alle CSS variablen naar light mode. CSS transition voor vloeiende overgang.
.dark / .light class op html element:root.light variablen toe// src/lib/theme.tsx export function ThemeProvider({ children }) { const [theme, setTheme] = useState<'dark' | 'light'>(getSettings().theme); useEffect(() => { document.documentElement.classList.toggle('dark', theme === 'dark'); document.documentElement.classList.toggle('light', theme === 'light'); }, [theme]); const toggle = () => { const next = theme === 'dark' ? 'light' : 'dark'; setTheme(next); saveSettings({ theme: next }); }; }
CSS: :root.light { --bg: #fff; --surface-900: #f8fafc; ... } +
* { transition: background-color 0.3s, border-color 0.3s; }
| Pagina | Huidig | Skeleton |
|---|---|---|
| Dashboard | — (data komt leeg binnen) | 4 pulsende card outlines |
| Peers | "Loading…" | 10 rij skeletten |
| Pins | "Loading…" | Lijst skeletten met icon + text |
| Users | "Loading…" | Tabel skeletten |
<Skeleton className="h-4 w-full" />// src/components/Skeleton.tsx export function Skeleton({ className }: { className?: string }) { return ( <div className={`animate-pulse rounded-md bg-surface-800 ${className ?? ''}`} style={{ animationDuration: '1.5s' }} /> ); } // Gebruik in pins pagina: {loading && ( <div className="divide-y divide-surface-800"> {Array.from({ length: 6 }).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> )}
// src/components/ErrorBoundary.tsx export class ErrorBoundary extends React.Component<{ fallback?: ReactNode }, { error: Error | null }> { state = { error: null }; static getDerivedStateFromError(error: Error) { return { error }; } render() { if (this.state.error) { return ( <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={() => this.setState({ error: null })} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm"> Probeer opnieuw </button> </div> ); } return this.props.children; } }
| Laag | Wat | Tool |
|---|---|---|
| Unit | helpers.ts — formatBytes, gatewayLink, truncateCid | Vitest |
| Unit | wallet.ts — formatWeiToETH, parseEther | Vitest |
| Unit | limits.ts — checkUploadAllowed | Vitest |
| Integratie | Catch-all route — matchRoute, mapToKuboAPI | Vitest + MSW |
| E2E | Dashboard laadt, peers tonen, upload flow | Playwright |
// HUIDIG — manual fetch + interval useEffect(() => { load(); // direct op mount const iv = setInterval(load, 15000); // elke 15s opnieuw return () => clearInterval(iv); }, []); // NIEUW — SWR (stale-while-revalidate) const { data: peers } = useSWR('/api/peers', fetch, { refreshInterval: 15000, // zelfde interval revalidateOnFocus: true, // + op tab focus dedupingInterval: 5000, // geen duplicaten });
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.
| # | Item | Priority | Fase | Nieuwe bestanden | Wijzigingen |
|---|---|---|---|---|---|
| 1 | Login / Sessie | Critical | F1 | 4 | 2 |
| 2 | Usage Limits | Critical | F1 | 2 | 1 |
| 3 | Notificaties | Critical | F1 | 3 | 1 |
| 4 | User Usage Overview | Critical | F1 | 3 | 1 |
| 5 | Self-Service | High | F1 | 2 | 0 |
| 6 | Batch Operaties | High | F2 | 1 | 2 |
| 7 | Dark/Light Mode | High | F2 | 1 | 3 |
| 8 | Skeleton Loaders | Medium | F2 | 1 | 4 |
| 9 | Error Boundary | Medium | F2 | 2 | 0 |
| 10 | Downloads | Medium | F2 | 1 | 2 |
| 11 | PWA | Low | F3 | 4 | 1 |
| 12 | Tests | Low | F3 | 4 | 0 |
| 13 | API Caching | Low | F3 | 0 | 5 |
| 14 | WebSocket/SSE | Low | F3 | 2 | 1 |
| 15 | IPFS Search | Low | F3 | 2 | 1 |
Begin met Fase 1 — dat zijn de 5 items die het verschil maken tussen "demo" en "productie":
→ Zeg het woord en ik maak er een uitvoerbaar plan van met todo's.