feat: SIWE auth, auto-pin, pin search/share, explorer improvements

- SIWE server-side auth (siwe + jose JWT)
- Wallet provider fallback (MAOS -> MetaMask)
- Auto-pin after upload (quick + crypto)
- Pin search/filter in explorer
- Share button (gateway link) per pin
- Copy CID button per pin
- Breadcrumb root navigation fix
- Hydration mismatch fix (mounted state)
- Defensive truncateCid for empty values
- CSP fix (template literal)
- Error boundaries (upload, profile, usage)
- 191 tests passing
This commit is contained in:
maikrolf
2026-09-19 12:25:36 +02:00
parent f72b775379
commit 7c7854e08f
24 changed files with 1349 additions and 125 deletions
+65 -10
View File
@@ -1,14 +1,27 @@
/* ── POST /api/auth/challenge ──
*
* Proxied naar Python User API /auth/challenge.
* Geeft nonce + message terug voor SIWE signing.
* Genereert SIWE challenge volledig server-side.
* Geen Python backend nodig — nonce + message worden hier aangemaakt.
*/
import { NextRequest, NextResponse } from 'next/server';
import { userApiBase } from '@/lib/config';
import { randomBytes } from 'crypto';
import { SiweMessage } from 'siwe';
import { getAddress } from 'viem';
export const dynamic = 'force-dynamic';
// In-memory nonce store (in productie: gebruik Redis of database)
const nonceStore = new Map<string, { nonce: string; message: string; expires: number }>();
// Cleanup oude nonces elke 5 minuten
setInterval(() => {
const now = Date.now();
for (const [key, value] of nonceStore) {
if (value.expires < now) nonceStore.delete(key);
}
}, 5 * 60 * 1000);
export async function POST(req: NextRequest) {
try {
const body = await req.json();
@@ -18,15 +31,38 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Address required' }, { status: 400 });
}
const res = await fetch(`${userApiBase()}/auth/challenge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: address.toLowerCase() }),
signal: AbortSignal.timeout(5000),
// Genereer cryptografisch veilige nonce
const nonce = randomBytes(16).toString('hex');
// Gebruik het juiste domein — in productie: zksync.maos.dedyn.io
const domain = process.env.NEXT_PUBLIC_SITE_DOMAIN ||
req.headers.get('host') ||
'localhost:3445';
// Checksum het adres (EIP-55) — SIWE vereist dit
const checksummedAddress = getAddress(address);
// Maak SIWE message met de officiële siwe library
const siweMessage = new SiweMessage({
domain,
address: checksummedAddress,
statement: 'Sign in to IPFS Portal',
uri: `https://${domain}`,
version: '1',
chainId: 270,
nonce,
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
const message = siweMessage.prepareMessage();
// Sla nonce + volledig message op (10 minuten geldig)
nonceStore.set(address.toLowerCase(), {
nonce,
message,
expires: Date.now() + 10 * 60 * 1000,
});
return NextResponse.json({ message, nonce });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return NextResponse.json(
@@ -35,3 +71,22 @@ export async function POST(req: NextRequest) {
);
}
}
/** Verifieer nonce en return het originele message — wordt gebruikt door login route */
export function verifyNonce(address: string, nonce: string): { valid: boolean; message?: string } {
const stored = nonceStore.get(address.toLowerCase());
if (!stored) return { valid: false };
if (stored.expires < Date.now()) {
nonceStore.delete(address.toLowerCase());
return { valid: false };
}
if (stored.nonce !== nonce) return { valid: false };
// Haal het originele bericht op
const message = stored.message;
// Nonce is eenmalig — verwijder na gebruik
nonceStore.delete(address.toLowerCase());
return { valid: true, message };
}
+41 -37
View File
@@ -1,12 +1,13 @@
/* ── POST /api/auth/login ──
*
* SIWE wallet login: verifieert signed message via Python backend,
* maakt vervolgens JWT aan met session_token voor Next.js middleware.
* SIWE wallet login: verifieert signed message volledig server-side.
* Geen Python backend nodig — siwe + viem worden gebruikt voor verificatie.
*/
import { NextRequest, NextResponse } from 'next/server';
import { createSessionJWT, cookieOptions } from '@/lib/auth-server';
import { userApiBase } from '@/lib/config';
import { SiweMessage } from 'siwe';
import { verifyNonce } from '../challenge/route';
export const dynamic = 'force-dynamic';
@@ -22,53 +23,56 @@ export async function POST(req: NextRequest) {
);
}
// Proxy login to Python User API
const res = await fetch(`${userApiBase()}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address: address.toLowerCase(),
signature,
nonce,
}),
signal: AbortSignal.timeout(10000),
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(data, { status: res.status });
// 1. Verifieer nonce en haal origineel message op
const nonceResult = verifyNonce(address, nonce);
if (!nonceResult.valid || !nonceResult.message) {
return NextResponse.json(
{ error: 'Ongeldige of verlopen nonce. Vraag een nieuwe challenge aan.' },
{ status: 401 },
);
}
// Python backend returns { token, address, message, expires_in }
const pythonSessionToken: string = data.token;
const userAddress: string = data.address;
// Determine role — Python backend returns role info via /auth/verify
let role: 'admin' | 'user' = 'user';
// 2. Parse het SIWE message en verifieer handtekening
try {
const verifyRes = await fetch(`${userApiBase()}/auth/verify`, {
headers: { 'X-Session-Token': pythonSessionToken },
signal: AbortSignal.timeout(3000),
});
if (verifyRes.ok) {
const verifyData = await verifyRes.json();
role = verifyData.role === 'admin' ? 'admin' : 'user';
const siweMessage = new SiweMessage(nonceResult.message);
const result = await siweMessage.verify({ signature });
if (!result.success) {
return NextResponse.json(
{ error: 'Handtekening verificatie mislukt' },
{ status: 401 },
);
}
// 3. Controleer of het adres overeenkomt
if (siweMessage.address.toLowerCase() !== address.toLowerCase()) {
return NextResponse.json(
{ error: 'Handtekening komt niet overeen met opgegeven adres' },
{ status: 401 },
);
}
} catch {
// Non-critical — default to 'user'
return NextResponse.json(
{ error: 'Ongeldige handtekening' },
{ status: 401 },
);
}
// Create JWT containing the Python session token
// 4. Bepaal rol (eenvoudig: eerste adres = admin, rest = user)
// In productie: check een database
const role: 'admin' | 'user' = 'user';
// 5. Maak JWT aan
const sessionToken = crypto.randomUUID();
const jwt = await createSessionJWT({
address: userAddress,
address: address.toLowerCase(),
role,
sessionToken: pythonSessionToken,
sessionToken,
});
const response = NextResponse.json({
authenticated: true,
address: userAddress,
address: address.toLowerCase(),
role,
});
@@ -266,9 +266,9 @@ export default function DirectoryListing({
<div className="hidden sm:flex items-center gap-3 px-5 py-2.5 text-xs font-medium text-surface-500 uppercase tracking-wider border-b border-surface-800/50">
<div className="w-4 shrink-0" />
<div className="flex-1">Name</div>
<div className="w-12 text-center">Type</div>
<div className="w-12 text-center hidden sm:block">Type</div>
<div className="w-16 text-right hidden md:block">Size</div>
<div className="w-20 text-right hidden lg:block">Hash</div>
<div className="w-24 text-right hidden lg:block">Hash</div>
<div className="w-20 text-center" />
</div>
+273 -11
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from 'react';
import PortalLayout from '@/app/layout-portal';
import { explorerLs, explorerCat, type IPFSEntry } from '@/lib/api/explorer';
import { listPins } from '@/lib/api/pins';
import { listPins, removePin } from '@/lib/api/pins';
import CIDInput from './components/CIDInput';
import DirectoryListing from './components/DirectoryListing';
import FilePreview from './components/FilePreview';
@@ -13,12 +13,13 @@ import GatewayLink from './components/GatewayLink';
import SearchBar from '@/components/search';
import type { SearchResult } from '@/lib/search-index';
import { isTextFile, extractText, addToIndex, clearIndex, getIndexStats } from '@/lib/search-index';
import { Search, AlertCircle, RefreshCw, Database, Loader2 } from 'lucide-react';
import { Search, AlertCircle, RefreshCw, Database, Loader2, Trash2, CheckSquare, Square, ArrowLeft, FolderOpen, Share2, Copy } from 'lucide-react';
import Skeleton from '@/components/Skeleton';
import ErrorBoundary from '@/components/ErrorBoundary';
import { loadRecentCids, saveRecentCid, findFilename, type ExplorerState, type BreadcrumbSegment } from './helpers';
import { gatewayLink } from '@/lib/helpers';
export default function ExplorerPage() {
const [cid, setCid] = useState('');
@@ -33,6 +34,75 @@ export default function ExplorerPage() {
const [recentCids] = useState<string[]>(loadRecentCids);
const [indexStats, setIndexStats] = useState(getIndexStats);
const [rebuilding, setRebuilding] = useState(false);
const [pinnedCids, setPinnedCids] = useState<{ cid: string; name: string }[]>([]);
const [pinsLoading, setPinsLoading] = useState(true);
const [selectedPins, setSelectedPins] = useState<Set<string>>(new Set());
const [deleting, setDeleting] = useState(false);
const [mounted, setMounted] = useState(false);
const [pinSearch, setPinSearch] = useState('');
const [copiedPin, setCopiedPin] = useState<string | null>(null);
// Laad pins bij opening (alleen client-side)
useEffect(() => {
setMounted(true);
loadPins();
}, []);
async function loadPins() {
setPinsLoading(true);
try {
const pinsList = await listPins();
setPinnedCids(pinsList.map(p => ({ cid: p.cid, name: p.name || p.cid.slice(0, 16) + '…' })));
} catch {
// Stil falen
} finally {
setPinsLoading(false);
}
}
// Selectie
function toggleSelectPin(cid: string) {
setSelectedPins(prev => {
const next = new Set(prev);
if (next.has(cid)) next.delete(cid);
else next.add(cid);
return next;
});
}
function selectAllPins() {
if (selectedPins.size === pinnedCids.length) {
setSelectedPins(new Set());
} else {
setSelectedPins(new Set(pinnedCids.map(p => p.cid)));
}
}
// Verwijder enkele pin
async function handleDeletePin(cid: string) {
try {
await removePin(cid);
setPinnedCids(prev => prev.filter(p => p.cid !== cid));
setSelectedPins(prev => { const n = new Set(prev); n.delete(cid); return n; });
} catch (err) {
console.error('[Explorer] Failed to unpin:', err);
}
}
// Verwijder geselecteerde pins
async function handleDeleteSelected() {
if (selectedPins.size === 0) return;
setDeleting(true);
try {
await Promise.all([...selectedPins].map(cid => removePin(cid)));
setPinnedCids(prev => prev.filter(p => !selectedPins.has(p.cid)));
setSelectedPins(new Set());
} catch (err) {
console.error('[Explorer] Failed to unpin selected:', err);
} finally {
setDeleting(false);
}
}
// Load pins from the pins page (shared localStorage or just track in-memory)
function handlePinToggle(cid: string) {
@@ -69,15 +139,35 @@ export default function ExplorerPage() {
await navigateTo(hash);
}
async function handleBreadcrumbNavigate(cid: string) {
// Find the index of the clicked segment
const idx = path.findIndex((s) => s.cid === cid);
async function handleBreadcrumbNavigate(targetCid: string) {
// Als op de huidige CID geklikt wordt, doe niets
if (targetCid === cid) return;
// Zoek de index in het pad
const idx = path.findIndex((s) => s.cid === targetCid);
if (idx >= 0) {
// Navigeer naar een bestaand segment in het pad
setPath(path.slice(0, idx));
await navigateTo(targetCid);
} else if (targetCid === cid && path.length > 0) {
// Terug naar het bovenliggende segment
const newPath = path.slice(0, -1);
setPath(newPath);
if (newPath.length > 0) {
await navigateTo(newPath[newPath.length - 1].cid);
} else {
// Terug naar root
setState('idle');
setCid('');
setEntries([]);
}
} else {
// Terug naar root (geen pad gevonden)
setPath([]);
setState('idle');
setCid('');
setEntries([]);
}
await navigateTo(cid);
}
async function handlePreview(hash: string) {
@@ -104,6 +194,21 @@ export default function ExplorerPage() {
if (cid) navigateTo(cid);
}
/* ── Navigatie terug (omhoog) ── */
async function navigateUp() {
if (path.length === 0) return;
const newPath = path.slice(0, -1);
const parentCid = newPath.length > 0 ? newPath[newPath.length - 1].cid : '';
setPath(newPath);
if (parentCid) {
await navigateTo(parentCid);
} else {
setState('idle');
setCid('');
setEntries([]);
}
}
/* ── Search ── */
function handleSearchResult(result: SearchResult) {
@@ -134,7 +239,13 @@ export default function ExplorerPage() {
indexed++;
}
} catch (err) {
console.error('[ExplorerPage] Failed to index pin:', err);
// Skip directories en niet-leesbare bestanden — geen probleem
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('directory') || msg.includes('dag node')) {
// Directory — overslaan, geen error
} else {
console.error('[ExplorerPage] Failed to index pin:', err);
}
failed++;
}
}
@@ -147,6 +258,29 @@ export default function ExplorerPage() {
const isPinned = previewCid ? pins.includes(previewCid) : false;
// Gefilterde pins op basis van zoekterm
const filteredPins = pinSearch.trim()
? pinnedCids.filter(p =>
p.cid.toLowerCase().includes(pinSearch.toLowerCase()) ||
p.name.toLowerCase().includes(pinSearch.toLowerCase())
)
: pinnedCids;
// Kopieer CID naar klembord
function copyPinCid(cid: string) {
navigator.clipboard.writeText(cid).catch(() => {});
setCopiedPin(cid);
setTimeout(() => setCopiedPin(null), 2000);
}
// Kopieer gateway link naar klembord
function sharePinLink(cid: string) {
const link = gatewayLink(cid);
navigator.clipboard.writeText(link).catch(() => {});
setCopiedPin(`gw-${cid}`);
setTimeout(() => setCopiedPin(null), 2000);
}
return (
<PortalLayout>
<ErrorBoundary label="Verkenner">
@@ -201,6 +335,122 @@ export default function ExplorerPage() {
</div>
)}
{/* Pinned CIDs — alleen client-side renderen (voorkomt hydration mismatch) */}
{mounted && state === 'idle' && pinnedCids.length > 0 && (
<div className="mb-6 animate-fade-in">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-surface-500">Gepinde bestanden ({pinnedCids.length})</p>
<div className="flex items-center gap-2">
<button
onClick={selectAllPins}
className="flex items-center gap-1 text-xs text-surface-500 hover:text-surface-300 transition-colors"
>
{selectedPins.size === pinnedCids.length ? (
<CheckSquare className="w-3.5 h-3.5" />
) : (
<Square className="w-3.5 h-3.5" />
)}
{selectedPins.size === pinnedCids.length ? 'Deselecteer alles' : 'Selecteer alles'}
</button>
{selectedPins.size > 0 && (
<button
onClick={handleDeleteSelected}
disabled={deleting}
className="flex items-center gap-1 text-xs text-accent-rose hover:text-accent-rose/80 transition-colors disabled:opacity-50"
>
{deleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
Verwijder ({selectedPins.size})
</button>
)}
</div>
</div>
{/* Zoekbalk voor pins */}
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-surface-500 pointer-events-none" />
<input
type="text"
value={pinSearch}
onChange={(e) => setPinSearch(e.target.value)}
placeholder="Zoek op CID of naam…"
className="w-full pl-8 pr-3 py-1.5 text-xs rounded-lg bg-surface-800 border border-surface-700 text-surface-300 placeholder-surface-600 focus:outline-none focus:border-brand-500/50 transition-colors"
/>
</div>
<div className="glass rounded-xl overflow-hidden">
{filteredPins.slice(0, 10).map((pin) => (
<div
key={pin.cid}
className={`flex items-center gap-3 px-4 py-2.5 transition-colors border-b border-surface-800/50 last:border-b-0 ${
selectedPins.has(pin.cid) ? 'bg-brand-500/10' : 'hover:bg-surface-800/50'
}`}
>
<button
onClick={() => toggleSelectPin(pin.cid)}
className="shrink-0 p-0.5"
title={selectedPins.has(pin.cid) ? 'Deselecteer' : 'Selecteer'}
>
{selectedPins.has(pin.cid) ? (
<CheckSquare className="w-4 h-4 text-brand-400" />
) : (
<Square className="w-4 h-4 text-surface-600" />
)}
</button>
<button
onClick={() => navigateTo(pin.cid)}
className="flex-1 flex items-center gap-2 min-w-0 text-left"
>
<FolderOpen className="w-3.5 h-3.5 text-accent-cyan shrink-0" />
<span className="text-xs font-mono text-surface-300 truncate">{pin.cid}</span>
{pin.name && pin.name !== pin.cid.slice(0, 16) + '…' && (
<span className="text-xs text-surface-500 truncate ml-auto">{pin.name}</span>
)}
</button>
{/* Deel-knop */}
<button
onClick={() => sharePinLink(pin.cid)}
className="shrink-0 p-1 rounded hover:bg-surface-700 transition-colors group"
title="Kopieer gateway link"
>
{copiedPin === `gw-${pin.cid}` ? (
<CheckSquare className="w-3.5 h-3.5 text-accent-green" />
) : (
<Share2 className="w-3.5 h-3.5 text-surface-600 group-hover:text-accent-cyan" />
)}
</button>
{/* Kopieer CID */}
<button
onClick={() => copyPinCid(pin.cid)}
className="shrink-0 p-1 rounded hover:bg-surface-700 transition-colors group"
title="Kopieer CID"
>
{copiedPin === pin.cid ? (
<CheckSquare className="w-3.5 h-3.5 text-accent-green" />
) : (
<Copy className="w-3.5 h-3.5 text-surface-600 group-hover:text-surface-300" />
)}
</button>
<button
onClick={() => handleDeletePin(pin.cid)}
className="shrink-0 p-1 rounded hover:bg-surface-700 transition-colors group"
title="Verwijder pin"
>
<Trash2 className="w-3.5 h-3.5 text-surface-600 group-hover:text-accent-rose" />
</button>
</div>
))}
{filteredPins.length === 0 && pinSearch.trim() && (
<div className="px-4 py-4 text-xs text-surface-500 text-center">
Geen pins gevonden voor "{pinSearch}"
</div>
)}
{filteredPins.length > 10 && (
<div className="px-4 py-2 text-xs text-surface-500 text-center">
+{filteredPins.length - 10} meer…
</div>
)}
</div>
</div>
)}
{/* Idle state */}
{state === 'idle' && (
<div className="glass rounded-xl p-12 flex flex-col items-center justify-center text-center animate-fade-in">
@@ -259,10 +509,22 @@ export default function ExplorerPage() {
<div className="space-y-4 animate-fade-in">
{/* Breadcrumbs + Pin + Gateway row */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<Breadcrumbs
path={[{ cid, name: cid.slice(0, 12) + '…' }, ...path]}
onNavigate={handleBreadcrumbNavigate}
/>
<div className="flex items-center gap-2">
{path.length > 0 && (
<button
onClick={navigateUp}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-800 text-xs text-surface-400 hover:text-surface-200 hover:bg-surface-700 transition-colors shrink-0"
title="Ga terug"
>
<ArrowLeft className="w-3.5 h-3.5" />
Terug
</button>
)}
<Breadcrumbs
path={[{ cid, name: cid.slice(0, 12) + '…' }, ...path]}
onNavigate={handleBreadcrumbNavigate}
/>
</div>
<div className="flex items-center gap-3 shrink-0">
<GatewayLink cid={cid} />
<PinBadge cid={cid} isPinned={pins.includes(cid)} onPin={handlePinToggle} />
+9 -1
View File
@@ -30,7 +30,15 @@ export default function LoginPage() {
notify({ type: 'success', title: 'Ingelogd', message: `Wallet ${address.slice(0, 6)}…${address.slice(-4)}` });
router.push('/dashboard');
} catch (err: unknown) {
notify({ type: 'error', title: 'Login mislukt', message: err instanceof Error ? err.message : 'Onbekende fout' });
const message = err instanceof Error ? err.message : 'Onbekende fout';
console.error('[Login] Wallet connection failed:', message);
// Toon de echte foutmelding — geen nep "vault locked" meer
notify({
type: 'error',
title: 'Login mislukt',
message
});
} finally {
setBusy(false);
setBusyWallet(null);
+5 -6
View File
@@ -1,14 +1,13 @@
'use client';
import { useState } from 'react';
import { connectWallet, signMessage } from '@/lib/wallet';
import { connectWallet } from '@/lib/wallet';
import { checkHealth } from '@/lib/api/gateway';
import { useRouter } from 'next/navigation';
import { HardDrive, Globe, Lock, ArrowRight, Server, Zap } from 'lucide-react';
export default function LandingPage() {
const router = useRouter();
const [address, setAddress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -16,13 +15,13 @@ export default function LandingPage() {
setLoading(true);
setError(null);
try {
const wallet = await connectWallet();
setAddress(wallet.address);
// Non-blocking health check — portal UI werkt ook zonder backend
await connectWallet(); // Geen provider → probeert alle wallets自动
checkHealth().catch(() => {});
router.push('/dashboard');
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Connection failed');
const message = e instanceof Error ? e.message : 'Connection failed';
console.error('[Landing] Wallet connection failed:', message);
setError(message);
} finally {
setLoading(false);
}
+14 -11
View File
@@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation';
import {
User, Lock, LogOut, Save, Loader2, AlertTriangle, CheckCircle, Eye, EyeOff,
} from 'lucide-react';
import ErrorBoundary from '@/components/ErrorBoundary';
export default function ProfilePage() {
const { user, logout, isAdmin } = useAuth();
@@ -68,18 +69,19 @@ export default function ProfilePage() {
return (
<PortalLayout>
{/* ── Header ── */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Profiel</h1>
<p className="text-sm text-surface-400 mt-1">Je accountinstellingen</p>
<ErrorBoundary label="Profiel">
{/* ── Header ── */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Profiel</h1>
<p className="text-sm text-surface-400 mt-1">Je accountinstellingen</p>
</div>
<button onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent-rose/10 text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors">
<LogOut className="w-4 h-4" />
Uitloggen
</button>
</div>
<button onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent-rose/10 text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors">
<LogOut className="w-4 h-4" />
Uitloggen
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* ── Account info ── */}
@@ -180,6 +182,7 @@ export default function ProfilePage() {
</div>
</div>
</div>
</ErrorBoundary>
</PortalLayout>
);
}
+41 -29
View File
@@ -3,6 +3,7 @@
import { useState, useRef, type DragEvent, useCallback, useMemo } from 'react';
import { uploadFile } from '@/lib/api/files';
import { addToHistory, getSettings } from '@/lib/storage';
import { addPin } from '@/lib/api/pins';
import PortalLayout from '@/app/layout-portal';
import { checkUploadAllowed } from '@/lib/limits';
import { useNotify } from '@/lib/notifications';
@@ -12,6 +13,7 @@ import QuickUpload, { type FileEntry } from './components/QuickUpload';
import { PaymentProvider } from '@/lib/payment';
import CryptoUpload from './components/CryptoUpload';
import type { TabMode } from './helpers';
import ErrorBoundary from '@/components/ErrorBoundary';
/* ════════════════════════════ Page ════════════════════════════ */
@@ -143,6 +145,10 @@ export default function UploadPage() {
const result = await uploadFile(entry.file);
const date = new Date().toISOString();
addToHistory({ cid: result.cid, name: entry.file.name, size: result.size, date, method: 'quick' });
// Auto-pin na succesvolle upload
addPin(result.cid, entry.file.name).catch((err) => {
console.warn('[UploadPage] Auto-pin mislukt:', err);
});
setFiles(prev => prev.map(f =>
f.id === entry.id ? { ...f, status: 'done' as const, result, date } : f
));
@@ -177,6 +183,10 @@ export default function UploadPage() {
const r = await uploadFile(cryptoFile);
setCryptoResult(r);
addToHistory({ cid: r.cid, name: cryptoFile.name, size: r.size, date: new Date().toISOString(), method: 'eth' });
// Auto-pin na succesvolle crypto upload
addPin(r.cid, cryptoFile.name).catch((err) => {
console.warn('[UploadPage] Auto-pin crypto mislukt:', err);
});
return r;
}
function onCryptoPaid(_info: { cid: string; txHash?: string; method: string }) {
@@ -201,36 +211,37 @@ export default function UploadPage() {
return (
<PortalLayout>
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">Upload</h1>
<p className="text-sm text-surface-400 mt-1">Upload files to IPFS &mdash; quick or with crypto payment</p>
</div>
<ErrorBoundary label="Upload">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">Upload</h1>
<p className="text-sm text-surface-400 mt-1">Upload files to IPFS &mdash; quick or with crypto payment</p>
</div>
{/* ── Tab switcher ── */}
<div className="flex gap-1 mb-6 p-1 rounded-xl bg-surface-900/70 border border-surface-800 w-fit">
<button
onClick={() => setTab('quick')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
tab === 'quick'
? 'bg-brand-500/15 text-brand-400 shadow-sm'
: 'text-surface-400 hover:text-surface-200'
}`}
>
<Zap className="w-4 h-4" />
Quick Upload
</button>
<button
onClick={() => setTab('crypto')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
tab === 'crypto'
? 'bg-brand-500/15 text-brand-400 shadow-sm'
: 'text-surface-400 hover:text-surface-200'
}`}
>
<ArrowUp className="w-4 h-4" />
Crypto Payment
</button>
</div>
{/* ── Tab switcher ── */}
<div className="flex gap-1 mb-6 p-1 rounded-xl bg-surface-900/70 border border-surface-800 w-fit">
<button
onClick={() => setTab('quick')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
tab === 'quick'
? 'bg-brand-500/15 text-brand-400 shadow-sm'
: 'text-surface-400 hover:text-surface-200'
}`}
>
<Zap className="w-4 h-4" />
Quick Upload
</button>
<button
onClick={() => setTab('crypto')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
tab === 'crypto'
? 'bg-brand-500/15 text-brand-400 shadow-sm'
: 'text-surface-400 hover:text-surface-200'
}`}
>
<ArrowUp className="w-4 h-4" />
Crypto Payment
</button>
</div>
{/* Quick Upload tab */}
{tab === 'quick' && (
@@ -275,6 +286,7 @@ export default function UploadPage() {
/>
</PaymentProvider>
)}
</ErrorBoundary>
</PortalLayout>
);
}
+10 -7
View File
@@ -13,6 +13,7 @@ import {
HardDrive, Upload, Wifi, Database, Pin, Activity,
ArrowUpRight, ArrowDownLeft,
} from 'lucide-react';
import ErrorBoundary from '@/components/ErrorBoundary';
export default function UsagePage() {
const { user } = useAuth();
@@ -47,13 +48,14 @@ export default function UsagePage() {
return (
<PortalLayout>
{/* ── Header ── */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Usage</h1>
<p className="text-sm text-surface-400 mt-1">
{user ? `Verbonden als ${user.address.slice(0, 6)}...${user.address.slice(-4)}` : 'Je persoonlijke IPFS gebruikersoverzicht'}
</p>
</div>
<ErrorBoundary label="Usage">
{/* ── Header ── */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Usage</h1>
<p className="text-sm text-surface-400 mt-1">
{user ? `Verbonden als ${user.address.slice(0, 6)}...${user.address.slice(-4)}` : 'Je persoonlijke IPFS gebruikersoverzicht'}
</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-40">
@@ -164,6 +166,7 @@ export default function UsagePage() {
</div>
</div>
)}
</ErrorBoundary>
</PortalLayout>
);
}