SIWE auth, API proxy fixes, dashboard, login, usage pages

- SIWE auth flow: challenge/login/logout/me API routes + JWT middleware
- Fixed [...path] catch-all: Kubo proxy with matchRoute() + mapToKuboAPI()
- Fixed session_id → nonce field mismatch in AuthProvider
- Fixed dashboard pins crash: Kubo returns {Keys: {...}}, not array
- Added middleware route guard (307 redirect to /login)
- Added login, register, profile, usage, upload pages
- Added dashboard components (StorageGauge, FreeTierProgress)
- Added SWR hooks + AuthGuard + Providers + Toast/ErrorBoundary
- Added payment integration (smart contract ABI, tiers, tokens)
- Fixed IPNS key listing: Name/Id → name/id mapping
- Added PWA support (manifest, service worker, icons)
- Added tests for helpers, limits, search-index, wallet
This commit is contained in:
maikrolf
2026-06-28 18:31:05 +02:00
parent 1ddc89479c
commit c9432a16ba
84 changed files with 6679 additions and 1426 deletions
+82 -412
View File
@@ -1,45 +1,26 @@
'use client';
import { useState, useRef, DragEvent, useCallback } from 'react';
import { useState, useRef, type DragEvent, useCallback } from 'react';
import { uploadFile } from '@/lib/api';
import { addToHistory, type UploadRecord } from '@/lib/storage';
import { addToHistory } from '@/lib/storage';
import PortalLayout from '@/app/layout-portal';
import PaymentPanel from '@/components/PaymentPanel';
import {
Upload, File, Image as FileImage, CheckCircle, XCircle, Loader2,
Copy, ExternalLink, Trash2, Globe, Clock, ArrowUp, Zap,
} from 'lucide-react';
import { checkUploadAllowed } from '@/lib/limits';
import { useNotify } from '@/lib/notifications';
import { Zap, ArrowUp } from 'lucide-react';
import QuickUpload, { type FileEntry } from './components/QuickUpload';
import CryptoUpload from './components/CryptoUpload';
/* ── Constants ── */
const MAX_FILES = 20;
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
/* ── Helpers ── */
function formatBytes(b: number): string {
if (b === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), units.length - 1);
const val = b / Math.pow(1024, i);
return (val < 10 ? val.toFixed(1) : Math.round(val)) + ' ' + units[i];
}
/* ── Types ── */
interface FileEntry {
id: string;
file: File;
preview?: string; // object URL for images
status: 'pending' | 'uploading' | 'done' | 'error';
result?: { cid: string; size: number };
errorMsg?: string;
date?: string;
}
type TabMode = 'quick' | 'crypto';
/* ════════════════════════════ Page ════════════════════════════ */
export default function UploadPage() {
const { notify } = useNotify();
const inputRef = useRef<HTMLInputElement>(null);
const cryptoInputRef = useRef<HTMLInputElement>(null);
@@ -87,6 +68,12 @@ export default function UploadPage() {
});
}
function clearAll() {
files.forEach(f => f.preview && URL.revokeObjectURL(f.preview));
setFiles([]);
setUploadDone(false);
}
/* ── Drag / Drop ── */
function onDragOver(e: DragEvent) { e.preventDefault(); setDragOver(true); }
function onDragLeave() { setDragOver(false); }
@@ -108,6 +95,18 @@ export default function UploadPage() {
const pending = files.filter(f => f.status === 'pending');
if (pending.length === 0) return;
// Check limits before uploading
const limitCheck = await checkUploadAllowed().catch(() => null);
if (limitCheck && !limitCheck.allowed) {
notify({
type: 'error',
title: 'Upload limiet bereikt',
message: limitCheck.reason || 'Je upload limiet is bereikt',
duration: 8000,
});
return;
}
setUploading(true);
setUploadIndex(0);
setUploadDone(false);
@@ -115,18 +114,13 @@ export default function UploadPage() {
for (let i = 0; i < pending.length; i++) {
const entry = pending[i];
// Mark uploading
setFiles(prev => prev.map(f => f.id === entry.id ? { ...f, status: 'uploading' as const } : f));
setUploadIndex(i + 1);
try {
const result = await uploadFile(entry.file);
const date = new Date().toISOString();
// Save to history
addToHistory({ cid: result.cid, name: entry.file.name, size: result.size, date, method: 'quick' });
// Mark done
setFiles(prev => prev.map(f =>
f.id === entry.id ? { ...f, status: 'done' as const, result, date } : f
));
@@ -145,18 +139,19 @@ export default function UploadPage() {
function onCryptoSelect() { cryptoInputRef.current?.click(); }
function onCryptoFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) {
setCryptoFile(file);
setCryptoResult(null);
}
if (file) { setCryptoFile(file); setCryptoResult(null); }
e.target.value = '';
}
function onCryptoClear() {
setCryptoFile(null);
setCryptoResult(null);
}
function onCryptoClear() { setCryptoFile(null); setCryptoResult(null); }
async function doCryptoUpload(): Promise<{ cid: string; size: number }> {
if (!cryptoFile) throw new Error('No file selected');
// Check limits before uploading
const limitCheck = await checkUploadAllowed().catch(() => null);
if (limitCheck && !limitCheck.allowed) {
throw new Error(limitCheck.reason || 'Upload limiet bereikt');
}
const r = await uploadFile(cryptoFile);
setCryptoResult(r);
addToHistory({ cid: r.cid, name: cryptoFile.name, size: r.size, date: new Date().toISOString(), method: 'eth' });
@@ -167,24 +162,23 @@ export default function UploadPage() {
}
/* ── Clipboard ── */
async function copyCid(cid: string) {
try {
await navigator.clipboard.writeText(cid);
setCopied(cid);
setTimeout(() => setCopied(null), 2000);
} catch { /* ignore */ }
function copyCid(cid: string) {
navigator.clipboard.writeText(cid).catch(() => {});
setCopied(cid);
setTimeout(() => setCopied(null), 2000);
}
function copyShareLink(cid: string) {
const link = gatewayLink(cid);
navigator.clipboard.writeText(link).catch(() => {});
setCopied(`gw-${cid}`);
setTimeout(() => setCopied(null), 2000);
}
function gatewayLink(cid: string) {
return `https://maos.dedyn.io/ipfs/${cid}`;
}
/* ── Stats ── */
const doneCount = files.filter(f => f.status === 'done').length;
const errorCount = files.filter(f => f.status === 'error').length;
const totalCount = files.length;
const allDone = files.every(f => f.status === 'done' || f.status === 'error');
/* ════════════ Render ════════════ */
return (
@@ -220,370 +214,46 @@ export default function UploadPage() {
</button>
</div>
{/* ════════════ TAB: Quick Upload ════════════ */}
{/* Quick Upload tab */}
{tab === 'quick' && (
<div>
{/* ── Drop zone ── */}
{!uploading && !uploadDone && (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={files.length < MAX_FILES ? onDrop : undefined}
onClick={files.length < MAX_FILES ? onBrowse : undefined}
className={`relative border-2 border-dashed rounded-xl p-10 text-center transition-all duration-200 cursor-pointer ${
dragOver
? 'border-brand-400 bg-brand-500/10'
: 'border-surface-700 hover:border-surface-500 bg-surface-900/50'
}`}
>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={onInputChange}
disabled={uploading || files.length >= MAX_FILES}
/>
<div className="flex flex-col items-center gap-3">
<Upload className="w-10 h-10 text-surface-500" />
<div>
<p className="text-sm text-surface-300">
<span className="text-brand-400 font-medium">Click to browse</span> or drag &amp; drop files
</p>
<p className="text-xs text-surface-500 mt-1">
Up to {MAX_FILES} files, max {formatBytes(MAX_FILE_SIZE)} each
</p>
</div>
</div>
</div>
)}
{/* ── File list ── */}
{files.length > 0 && (
<div className="mt-6 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-surface-400">
{totalCount} file{totalCount !== 1 ? 's' : ''} selected
{uploading && <> &middot; Uploading {uploadIndex}/{totalCount}</>}
</span>
{!uploading && !allDone && (
<button
onClick={() => { files.forEach(f => f.preview && URL.revokeObjectURL(f.preview)); setFiles([]); setUploadDone(false); }}
className="text-xs text-surface-500 hover:text-accent-rose transition-colors"
>
Clear all
</button>
)}
</div>
{/* Progress bar */}
{uploading && (
<div className="w-full h-1.5 rounded-full bg-surface-800 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-brand-500 to-accent-cyan transition-all duration-300"
style={{ width: `${(uploadIndex / totalCount) * 100}%` }}
/>
</div>
)}
{/* Files */}
<div className="space-y-2">
{files.map((entry, idx) => (
<div
key={entry.id}
className={`glass rounded-xl p-3 flex items-center gap-3 transition-all duration-200 animate-fade-in ${
entry.status === 'done' ? 'border-l-2 border-l-accent-green' :
entry.status === 'error' ? 'border-l-2 border-l-accent-rose' : ''
}`}
style={{ animationDelay: `${idx * 30}ms` }}
>
{/* Preview / icon */}
<div className="w-10 h-10 rounded-lg bg-surface-800 flex items-center justify-center shrink-0 overflow-hidden">
{entry.preview ? (
<img src={entry.preview} alt="" className="w-full h-full object-cover" />
) : (
<File className="w-5 h-5 text-surface-500" />
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-surface-200 truncate">{entry.file.name}</span>
{entry.status === 'done' && entry.result && (
<CheckCircle className="w-3.5 h-3.5 text-accent-green shrink-0" />
)}
{entry.status === 'error' && (
<XCircle className="w-3.5 h-3.5 text-accent-rose shrink-0" />
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-surface-500">{formatBytes(entry.file.size)}</span>
{entry.status === 'uploading' && (
<span className="text-xs text-brand-400 flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Uploading…
</span>
)}
{entry.status === 'done' && entry.result && (
<span className="text-xs font-mono text-surface-400 truncate max-w-[160px]">
{entry.result.cid.slice(0, 16)}…
</span>
)}
{entry.status === 'error' && (
<span className="text-xs text-accent-rose truncate max-w-[200px]">
{entry.errorMsg}
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
{entry.status === 'done' && entry.result && (
<>
<button
onClick={() => copyCid(entry.result!.cid)}
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
title="Copy CID"
>
{copied === entry.result!.cid ? (
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
) : (
<Copy className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
)}
</button>
<a
href={gatewayLink(entry.result!.cid)}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
title="Open in gateway"
>
<ExternalLink className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
</a>
</>
)}
{entry.status === 'pending' && !uploading && (
<button
onClick={() => removeFile(entry.id)}
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
title="Remove"
>
<Trash2 className="w-3.5 h-3.5 text-surface-500 group-hover:text-accent-rose" />
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* ── Action buttons ── */}
{files.length > 0 && !allDone && (
<div className="mt-4">
<button
onClick={startUpload}
disabled={uploading || files.filter(f => f.status === 'pending').length === 0}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
>
{uploading ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Uploading…</>
) : (
<><Upload className="w-4 h-4" /> Upload All ({files.filter(f => f.status === 'pending').length})</>
)}
</button>
</div>
)}
{/* ── Results summary ── */}
{uploadDone && (
<div className="mt-6 glass rounded-xl p-5 animate-fade-in">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
{errorCount === 0 ? (
<CheckCircle className="w-5 h-5 text-accent-green" />
) : (
<XCircle className="w-5 h-5 text-accent-rose" />
)}
<span className="text-sm font-medium text-white">
{doneCount}/{totalCount} files uploaded
</span>
</div>
<button
onClick={() => { files.forEach(f => f.preview && URL.revokeObjectURL(f.preview)); setFiles([]); setUploadDone(false); }}
className="flex items-center gap-1 text-xs text-brand-400 hover:text-brand-300 transition-colors"
>
<Upload className="w-3 h-3" />
Upload more
</button>
</div>
{/* Per-file result details */}
{files.filter(f => f.status === 'done').map(entry => entry.result && (
<div key={entry.id} className="flex items-center justify-between py-2 px-3 rounded-lg bg-surface-800/50 mb-1.5">
<div className="flex items-center gap-3 min-w-0 flex-1">
<span className="text-sm text-surface-300 truncate max-w-[150px]">{entry.file.name}</span>
<span className="text-xs font-mono text-surface-500 truncate max-w-[120px]">{entry.result.cid.slice(0, 16)}…</span>
<span className="text-xs text-surface-500">{formatBytes(entry.result.size)}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => copyCid(entry.result!.cid)}
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
title="Copy CID"
>
{copied === entry.result!.cid ? (
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
) : (
<Copy className="w-3.5 h-3.5 text-surface-500" />
)}
</button>
<button
onClick={() => {
const link = gatewayLink(entry.result!.cid);
navigator.clipboard.writeText(link).catch(() => {});
setCopied(`gw-${entry.result!.cid}`);
setTimeout(() => setCopied(null), 2000);
}}
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
title="Copy share link"
>
{copied === `gw-${entry.result!.cid}` ? (
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
) : (
<Globe className="w-3.5 h-3.5 text-surface-500" />
)}
</button>
<a
href={gatewayLink(entry.result!.cid)}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
title="Open in gateway"
>
<ExternalLink className="w-3.5 h-3.5 text-surface-500" />
</a>
</div>
</div>
))}
</div>
)}
{/* ── Empty state ── */}
{files.length === 0 && !uploading && (
<div className="mt-6 glass rounded-xl p-8 text-center">
<Upload className="w-8 h-8 text-surface-600 mx-auto mb-3" />
<p className="text-sm text-surface-500">
Drag files above or click to browse. No crypto payment needed for quick uploads.
</p>
</div>
)}
</div>
<QuickUpload
files={files}
dragOver={dragOver}
uploading={uploading}
uploadIndex={uploadIndex}
uploadDone={uploadDone}
copied={copied}
inputRef={inputRef}
onAddFiles={addFiles}
onRemoveFile={removeFile}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onBrowse={onBrowse}
onInputChange={onInputChange}
onStartUpload={startUpload}
onCopyCid={copyCid}
onCopyShareLink={copyShareLink}
onClearAll={clearAll}
gatewayLink={gatewayLink}
/>
)}
{/* ════════════ TAB: Crypto Payment ════════════ */}
{/* Crypto Payment tab */}
{tab === 'crypto' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: file selection */}
<div className="lg:col-span-2">
{!cryptoFile ? (
<div
onClick={onCryptoSelect}
className="border-2 border-dashed border-surface-700 hover:border-surface-500 rounded-xl p-12 text-center cursor-pointer transition-all bg-surface-900/50"
>
<input
ref={cryptoInputRef}
type="file"
className="hidden"
onChange={onCryptoFileChange}
/>
<div className="flex flex-col items-center gap-3">
<ArrowUp className="w-10 h-10 text-surface-500" />
<div>
<p className="text-sm text-surface-300">
<span className="text-brand-400 font-medium">Click to select</span> a file
</p>
<p className="text-xs text-surface-500 mt-1">
Single file only. Pay with ETH, USDC, or MAOS.
</p>
</div>
</div>
</div>
) : (
<div className="glass rounded-xl p-5">
<div className="flex items-center gap-3">
<File className="w-8 h-8 text-brand-400" />
<div className="flex-1 min-w-0">
<p className="text-sm text-surface-200 font-medium truncate">{cryptoFile.name}</p>
<p className="text-xs text-surface-500">{formatBytes(cryptoFile.size)}</p>
</div>
<button
onClick={onCryptoClear}
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
title="Remove"
>
<Trash2 className="w-4 h-4 text-surface-500 hover:text-accent-rose" />
</button>
</div>
{/* Crypto result */}
{cryptoResult && (
<div className="mt-4 space-y-2 p-3 rounded-lg bg-accent-green/10 border border-accent-green/20 animate-fade-in">
<div className="flex items-center gap-2 text-accent-green">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-medium">Upload complete</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-surface-400">CID</span>
<div className="flex items-center gap-1">
<span className="font-mono text-surface-300 truncate max-w-[180px]">{cryptoResult.cid}</span>
<button onClick={() => copyCid(cryptoResult.cid)} className="p-0.5 hover:text-surface-100">
{copied === cryptoResult.cid ? (
<CheckCircle className="w-3 h-3 text-accent-green" />
) : (
<Copy className="w-3 h-3 text-surface-500" />
)}
</button>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-surface-400">Gateway</span>
<a
href={gatewayLink(cryptoResult.cid)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-brand-400 hover:text-brand-300"
>
View <ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
)}
</div>
)}
</div>
{/* Right: PaymentPanel */}
<div className="lg:col-span-1">
{cryptoFile ? (
<PaymentPanel
fileSize={cryptoFile.size}
fileName={cryptoFile.name}
onPaid={onCryptoPaid}
onUpload={doCryptoUpload}
/>
) : (
<div className="glass rounded-xl p-5 text-center">
<ArrowUp className="w-6 h-6 text-surface-600 mx-auto mb-2" />
<p className="text-xs text-surface-500">
Select a file to see pricing
</p>
</div>
)}
</div>
</div>
<CryptoUpload
cryptoFile={cryptoFile}
cryptoResult={cryptoResult}
copied={copied}
cryptoInputRef={cryptoInputRef}
onCryptoSelect={onCryptoSelect}
onCryptoFileChange={onCryptoFileChange}
onCryptoClear={onCryptoClear}
onCryptoPaid={onCryptoPaid}
doCryptoUpload={doCryptoUpload}
onCopyCid={copyCid}
gatewayLink={gatewayLink}
/>
)}
</PortalLayout>
);