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
@@ -0,0 +1,108 @@
'use client';
import type { TokenInfo } from '@/lib/payment';
import { formatWeiToETH } from '@/lib/wallet';
/* ── Props ── */
interface OverviewProps {
totalUploads: bigint;
tokens: TokenInfo[];
revenues: Record<string, bigint>;
contractBalance: Record<string, string>;
contractAddress: string;
owner: string | null;
isOwner: boolean;
address: string | null;
deployed: boolean;
onNavigate: (view: string) => void;
}
/* ════════════════════════════ Component ════════════════════════════ */
export default function Overview({
totalUploads, tokens, revenues, contractBalance,
contractAddress, owner, isOwner, address, deployed, onNavigate,
}: OverviewProps) {
return (
<div className="space-y-6">
{/* Stats grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="glass rounded-xl p-4">
<div className="text-xs text-surface-400 mb-1">Total Uploads</div>
<div className="text-2xl font-bold text-white">{totalUploads.toString()}</div>
</div>
{tokens.filter(t => t.enabled).map(tk => {
const rev = revenues[tk.symbol] || BigInt(0);
const bal = contractBalance[tk.symbol];
return (
<div key={tk.symbol} className="glass rounded-xl p-4">
<div className="text-xs text-surface-400 mb-1">{tk.symbol} Revenue</div>
<div className="text-2xl font-bold text-white">
{tk.symbol === 'ETH' ? formatWeiToETH(rev, 6) : rev.toString()}
</div>
{bal && (
<div className="text-[10px] text-surface-500 mt-1">
Balance: {tk.symbol === 'ETH' ? formatWeiToETH(bal, 6) : bal} {tk.symbol}
</div>
)}
</div>
);
})}
</div>
{/* Contract info */}
<div className="glass rounded-xl p-5 space-y-3">
<h3 className="text-sm font-semibold text-white">Contract</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
<span className="text-surface-400">Address</span>
<span className="font-mono text-surface-200">
{contractAddress.slice(0, 10)}...
</span>
</div>
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
<span className="text-surface-400">Owner</span>
<span className="font-mono text-surface-200">
{owner ? `${owner.slice(0, 8)}...${owner.slice(-6)}` : '-'}
{isOwner && <span className="ml-1 text-accent-green"></span>}
</span>
</div>
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
<span className="text-surface-400">Deployed</span>
<span className={deployed ? 'text-accent-green' : 'text-accent-rose'}>
{deployed ? 'Yes' : 'No'}
</span>
</div>
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
<span className="text-surface-400">Connected</span>
<span className="text-surface-200 font-mono">
{address ? `${address.slice(0, 6)}...${address.slice(-4)}` : '-'}
</span>
</div>
</div>
</div>
{/* If owner — quick actions */}
{isOwner && (
<div className="glass rounded-xl p-5 space-y-3">
<h3 className="text-sm font-semibold text-white">Quick Actions</h3>
<div className="flex flex-wrap gap-2">
<button onClick={() => onNavigate('pricing')}
className="px-3 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-300 text-xs transition-colors">
Update Pricing
</button>
<button onClick={() => onNavigate('tokens')}
className="px-3 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-300 text-xs transition-colors">
Manage Tokens
</button>
<button onClick={() => onNavigate('tiers')}
className="px-3 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-300 text-xs transition-colors">
Discount Tiers
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,124 @@
'use client';
import { type Dispatch, type SetStateAction } from 'react';
import { Settings } from 'lucide-react';
import { paymentService, type TokenInfo } from '@/lib/payment';
import type { WalletProvider } from '@/lib/wallet';
import { formatWeiToETH, formatBytes } from '@/lib/wallet';
/* ── Props ── */
interface PricingViewProps {
basePrice: bigint;
freeTier: bigint;
isOwner: boolean;
tokens: TokenInfo[];
contractBalance: Record<string, string>;
priceInput: string;
setPriceInput: Dispatch<SetStateAction<string>>;
freeInput: string;
setFreeInput: Dispatch<SetStateAction<string>>;
doTx: (label: string, fn: () => Promise<string>) => void;
provider: WalletProvider | null;
address: string | null;
}
/* ════════════════════════════ Component ════════════════════════════ */
export default function PricingView({
basePrice, freeTier, isOwner, tokens, contractBalance,
priceInput, setPriceInput, freeInput, setFreeInput,
doTx, provider, address,
}: PricingViewProps) {
return (
<div className="max-w-2xl space-y-4">
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Settings className="w-4 h-4 text-brand-400" /> Pricing Config
</h3>
{/* Current values */}
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="px-3 py-2 rounded bg-surface-800/30">
<div className="text-surface-400">Base Price / MB</div>
<div className="text-surface-200 font-medium mt-0.5">{formatWeiToETH(basePrice, 8)} ETH</div>
</div>
<div className="px-3 py-2 rounded bg-surface-800/30">
<div className="text-surface-400">Free Tier</div>
<div className="text-surface-200 font-medium mt-0.5">{formatBytes(freeTier)}</div>
</div>
</div>
{isOwner && (
<>
{/* Set price */}
<div className="space-y-2 pt-3 border-t border-surface-800">
<label className="text-xs text-surface-400">Set Base Price / MB (wei)</label>
<div className="flex gap-2">
<input value={priceInput} onChange={e => setPriceInput(e.target.value)}
placeholder={basePrice.toString()}
className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
/>
<button onClick={() => {
if (!priceInput) return;
const wei = BigInt(priceInput);
doTx('Set price', () => paymentService.adminSetPricePerMB(provider!, wei));
}}
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors">
Update
</button>
</div>
<div className="text-[10px] text-surface-500">
Current: {formatWeiToETH(basePrice, 8)} ETH e.g. 100000000000000 = 0.0001 ETH
</div>
</div>
{/* Set free tier */}
<div className="space-y-2 pt-3 border-t border-surface-800">
<label className="text-xs text-surface-400">Set Free Tier (bytes)</label>
<div className="flex gap-2">
<input value={freeInput} onChange={e => setFreeInput(e.target.value)}
placeholder={freeTier.toString()}
className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
/>
<button onClick={() => {
if (!freeInput) return;
doTx('Set free tier', () => paymentService.adminSetFreeTierBytes(provider!, BigInt(freeInput)));
}}
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors">
Update
</button>
</div>
<div className="text-[10px] text-surface-500">
Current: {formatBytes(freeTier)} 10485760 = 10 MB
</div>
</div>
{/* Withdraw */}
<div className="pt-3 border-t border-surface-800 space-y-2">
<label className="text-xs text-surface-400">Withdraw Funds</label>
<div className="flex flex-wrap gap-2">
{tokens.filter(t => t.enabled).map(tk => {
const bal = contractBalance[tk.symbol];
if (!bal || bal === '0') return null;
return (
<button key={tk.symbol} onClick={() =>
doTx(`Withdraw ${tk.symbol}`, () =>
tk.symbol === 'ETH'
? paymentService.adminWithdrawETH(provider!, address as `0x${string}`)
: paymentService.adminWithdrawToken(provider!, tk.symbol, address as `0x${string}`)
)
}
className="px-3 py-1.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 text-amber-400 text-xs transition-colors">
Withdraw {tk.symbol} ({tk.symbol === 'ETH' ? formatWeiToETH(bal, 4) : bal})
</button>
);
})}
</div>
</div>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,88 @@
'use client';
import { type Dispatch, type SetStateAction } from 'react';
import { Tags, Plus, Trash2 } from 'lucide-react';
import { paymentService, type MAOSDiscountTier } from '@/lib/payment';
import type { WalletProvider } from '@/lib/wallet';
import { formatWeiToETH } from '@/lib/wallet';
/* ── Props ── */
interface TiersViewProps {
tiers: MAOSDiscountTier[];
isOwner: boolean;
tierBalance: string;
setTierBalance: Dispatch<SetStateAction<string>>;
tierBps: string;
setTierBps: Dispatch<SetStateAction<string>>;
doTx: (label: string, fn: () => Promise<string>) => void;
provider: WalletProvider | null;
}
/* ════════════════════════════ Component ════════════════════════════ */
export default function TiersView({
tiers, isOwner,
tierBalance, setTierBalance, tierBps, setTierBps,
doTx, provider,
}: TiersViewProps) {
return (
<div className="max-w-2xl space-y-4">
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Tags className="w-4 h-4 text-brand-400" /> MAOS Discount Tiers
</h3>
{/* Tier list */}
<div className="space-y-2">
{tiers.map((tier, i) => (
<div key={i} className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/40 text-xs">
<div className="flex items-center gap-3">
<span className="text-surface-200 font-medium">{formatWeiToETH(tier.minBalance, 0)} MAOS</span>
<span className="px-1.5 py-0.5 rounded bg-accent-cyan/10 text-accent-cyan">{tier.discountBps / 100}% off</span>
</div>
{isOwner && (
<button onClick={() => doTx('Remove tier', () => paymentService.adminRemoveDiscountTier(provider!, i))}
className="p-1 rounded hover:bg-accent-rose/10 text-surface-400 hover:text-accent-rose transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
</div>
</div>
{/* Admin: add tier */}
{isOwner && (
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Plus className="w-4 h-4 text-brand-400" /> Add / Update Tier
</h3>
<div className="flex gap-2 items-end">
<div className="flex-1 space-y-1">
<label className="text-[10px] text-surface-500">Min MAOS balance</label>
<input value={tierBalance} onChange={e => setTierBalance(e.target.value)}
placeholder="e.g. 5000" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-[10px] text-surface-500">Discount %</label>
<input value={tierBps} onChange={e => setTierBps(e.target.value)}
placeholder="e.g. 50" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
/>
</div>
<button onClick={() => {
if (!tierBalance || !tierBps) return;
const bal = BigInt(parseFloat(tierBalance) * 1e18);
const bps = parseInt(tierBps) * 100;
doTx('Set tier', () => paymentService.adminSetDiscountTier(provider!, bal, bps));
}}
className="px-4 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors whitespace-nowrap">
<Plus className="w-3.5 h-3.5 inline mr-1" /> Add
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,124 @@
'use client';
import { type Dispatch, type SetStateAction } from 'react';
import { Coins, PiggyBank } from 'lucide-react';
import { paymentService, type TokenInfo } from '@/lib/payment';
import type { WalletProvider } from '@/lib/wallet';
import { formatWeiToETH } from '@/lib/wallet';
/* ── Props ── */
interface TokensViewProps {
tokens: TokenInfo[];
isOwner: boolean;
contractBalance: Record<string, string>;
tokenSymbol: string;
setTokenSymbol: Dispatch<SetStateAction<string>>;
tokenAddr: string;
setTokenAddr: Dispatch<SetStateAction<string>>;
tokenDec: string;
setTokenDec: Dispatch<SetStateAction<string>>;
tokenWei: string;
setTokenWei: Dispatch<SetStateAction<string>>;
tokenEnabled: boolean;
setTokenEnabled: Dispatch<SetStateAction<boolean>>;
doTx: (label: string, fn: () => Promise<string>) => void;
provider: WalletProvider | null;
address: string | null;
}
/* ════════════════════════════ Component ════════════════════════════ */
export default function TokensView({
tokens, isOwner, contractBalance,
tokenSymbol, setTokenSymbol, tokenAddr, setTokenAddr,
tokenDec, setTokenDec, tokenWei, setTokenWei,
tokenEnabled, setTokenEnabled, doTx, provider, address,
}: TokensViewProps) {
return (
<div className="max-w-2xl space-y-4">
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Coins className="w-4 h-4 text-brand-400" /> Supported Tokens
</h3>
{/* Token list */}
<div className="space-y-2">
{tokens.map(tk => (
<div key={tk.symbol} className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/40 text-xs">
<div className="flex items-center gap-3">
<span className={`w-2 h-2 rounded-full ${tk.enabled ? 'bg-accent-green' : 'bg-surface-500'}`} />
<span className="font-medium text-surface-200">{tk.symbol}</span>
<span className="font-mono text-surface-500">{tk.address.slice(0, 10)}...</span>
<span className="text-surface-500">{tk.decimals} decimals</span>
</div>
<span className="text-surface-400">{formatWeiToETH(tk.weiPerToken, 4)} wei/token</span>
</div>
))}
</div>
</div>
{/* Admin: configure token */}
{isOwner && (
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white">Configure Token</h3>
<div className="grid grid-cols-2 gap-3 text-xs">
<input value={tokenSymbol} onChange={e => setTokenSymbol(e.target.value.toUpperCase())}
placeholder="Symbol (e.g. USDC)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
/>
<input value={tokenAddr} onChange={e => setTokenAddr(e.target.value)}
placeholder="Token address (0x...)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
/>
<input value={tokenDec} onChange={e => setTokenDec(e.target.value)}
placeholder="Decimals (e.g. 6)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
/>
<input value={tokenWei} onChange={e => setTokenWei(e.target.value)}
placeholder="weiPerToken (e.g. 500000000000000)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
/>
<label className="flex items-center gap-2 text-surface-400">
<input type="checkbox" checked={tokenEnabled} onChange={e => setTokenEnabled(e.target.checked)}
className="rounded border-surface-600"
/>
Enabled
</label>
<button onClick={() => {
if (!tokenSymbol || !tokenAddr) return;
doTx('Configure token', () => paymentService.adminConfigureToken(
provider!, tokenSymbol, tokenAddr as `0x${string}`, parseInt(tokenDec), BigInt(tokenWei || '0'), tokenEnabled
));
}}
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white font-medium transition-colors">
Apply
</button>
</div>
</div>
)}
{/* Withdraw section */}
{isOwner && Object.keys(contractBalance).length > 0 && (
<div className="glass rounded-xl p-5 space-y-3">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<PiggyBank className="w-4 h-4 text-brand-400" /> Withdraw
</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(contractBalance).filter(([_, bal]) => bal !== '0').map(([sym, bal]) => (
<button key={sym} onClick={() =>
doTx(`Withdraw ${sym}`, () =>
sym === 'ETH'
? paymentService.adminWithdrawETH(provider!, address as `0x${string}`)
: paymentService.adminWithdrawToken(provider!, sym, address as `0x${string}`)
)
}
className="px-3 py-1.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 text-amber-400 text-xs transition-colors">
Withdraw {sym} ({sym === 'ETH' ? formatWeiToETH(bal, 4) : bal})
</button>
))}
{Object.values(contractBalance).every(b => b === '0') && (
<span className="text-xs text-surface-500">No balance to withdraw</span>
)}
</div>
</div>
)}
</div>
);
}
+66 -323
View File
@@ -2,16 +2,20 @@
import { useState, useEffect, useCallback } from 'react';
import PortalLayout from '@/app/layout-portal';
import AuthGuard from '@/components/AuthGuard';
import { paymentService, type TokenInfo, type MAOSDiscountTier } from '@/lib/payment';
import {
connectWallet, switchToChain270, getInjectedProvider,
formatWeiToETH, formatBytes, type WalletProvider,
} from '@/lib/wallet';
import {
Wallet, Coins, Loader2, CheckCircle, XCircle,
Settings, PiggyBank, Tags, RefreshCw, ExternalLink,
Plus, Trash2, Copy, AlertTriangle,
Wallet, Loader2, CheckCircle, XCircle,
RefreshCw, AlertTriangle,
} from 'lucide-react';
import Overview from './components/Overview';
import PricingView from './components/PricingView';
import TokensView from './components/TokensView';
import TiersView from './components/TiersView';
type ViewMode = 'overview' | 'pricing' | 'tokens' | 'tiers';
type TxStatus = { ok: boolean; msg: string } | null;
@@ -37,14 +41,14 @@ export default function AdminPaymentPage() {
const [loading, setLoading] = useState(true);
const [contractBalance, setContractBalance] = useState<Record<string, string>>({});
const isOwner = address && owner && address.toLowerCase() === owner.toLowerCase();
const isOwner = !!(address && owner && address.toLowerCase() === owner.toLowerCase());
// ── Load all contract state ──
const refresh = useCallback(async () => {
setLoading(true);
setTxStatus(null);
try {
paymentService.isDeployed().then(setDeployed);
paymentService.isDeployed().then(setDeployed).catch(() => setDeployed(false));
const [own, price, free, t, tiersData, uploads] = await Promise.all([
paymentService.getOwner(),
paymentService.getBasePricePerMB(),
@@ -58,8 +62,7 @@ export default function AdminPaymentPage() {
setFreeTier(free);
setTiers(tiersData);
// Revenue per token
// Deduplicate by symbol (contract has a bug where USDC appears twice)
// Deduplicate by symbol
const seen = new Set<string>();
const uniqueTokens = t.filter(tk => {
if (seen.has(tk.symbol)) return false;
@@ -122,11 +125,8 @@ export default function AdminPaymentPage() {
const switched = await switchToChain270(prov || undefined);
if (switched) setWrongChain(false);
}
// @ts-expect-error
if (window.maosv6) setWalletType('MAOS Wallet');
// @ts-expect-error
else if (window.ethereum?.isRabby) setWalletType('Rabby');
// @ts-expect-error
else if (window.ethereum?.isMetaMask) setWalletType('MetaMask');
else setWalletType('Wallet');
} catch (e: any) {
@@ -162,6 +162,7 @@ export default function AdminPaymentPage() {
const [tokenEnabled, setTokenEnabled] = useState(true);
return (
<AuthGuard requireAdmin redirectTo="/dashboard">
<PortalLayout>
{/* ── Header ── */}
<div className="flex items-center justify-between mb-6">
@@ -224,329 +225,70 @@ export default function AdminPaymentPage() {
</div>
) : (
<>
{/* ── Overview ── */}
{view === 'overview' && (
<div className="space-y-6">
{/* Stats grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="glass rounded-xl p-4">
<div className="text-xs text-surface-400 mb-1">Total Uploads</div>
<div className="text-2xl font-bold text-white">{totalUploads.toString()}</div>
</div>
{tokens.filter(t => t.enabled).map(tk => {
const rev = revenues[tk.symbol] || BigInt(0);
const bal = contractBalance[tk.symbol];
return (
<div key={tk.symbol} className="glass rounded-xl p-4">
<div className="text-xs text-surface-400 mb-1">{tk.symbol} Revenue</div>
<div className="text-2xl font-bold text-white">
{tk.symbol === 'ETH' ? formatWeiToETH(rev, 6) : rev.toString()}
</div>
{bal && (
<div className="text-[10px] text-surface-500 mt-1">
Balance: {tk.symbol === 'ETH' ? formatWeiToETH(bal, 6) : bal} {tk.symbol}
</div>
)}
</div>
);
})}
</div>
{/* Contract info */}
<div className="glass rounded-xl p-5 space-y-3">
<h3 className="text-sm font-semibold text-white">Contract</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
<span className="text-surface-400">Address</span>
<span className="font-mono text-surface-200">
{paymentService['contractAddress'].slice(0, 10)}...
</span>
</div>
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
<span className="text-surface-400">Owner</span>
<span className="font-mono text-surface-200">
{owner ? `${owner.slice(0, 8)}...${owner.slice(-6)}` : '-'}
{isOwner && <span className="ml-1 text-accent-green"></span>}
</span>
</div>
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
<span className="text-surface-400">Deployed</span>
<span className={deployed ? 'text-accent-green' : 'text-accent-rose'}>
{deployed ? 'Yes' : 'No'}
</span>
</div>
<div className="flex justify-between py-1.5 px-3 rounded bg-surface-800/30">
<span className="text-surface-400">Connected</span>
<span className="text-surface-200 font-mono">
{address.slice(0, 6)}...{address.slice(-4)}
</span>
</div>
</div>
</div>
{/* If owner — quick actions */}
{isOwner && (
<div className="glass rounded-xl p-5 space-y-3">
<h3 className="text-sm font-semibold text-white">Quick Actions</h3>
<div className="flex flex-wrap gap-2">
<button onClick={() => setView('pricing')}
className="px-3 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-300 text-xs transition-colors">
Update Pricing
</button>
<button onClick={() => setView('tokens')}
className="px-3 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-300 text-xs transition-colors">
Manage Tokens
</button>
<button onClick={() => setView('tiers')}
className="px-3 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-300 text-xs transition-colors">
Discount Tiers
</button>
</div>
</div>
)}
</div>
<Overview
totalUploads={totalUploads}
tokens={tokens}
revenues={revenues}
contractBalance={contractBalance}
contractAddress={paymentService['contractAddress']}
owner={owner}
isOwner={isOwner}
address={address}
deployed={deployed}
onNavigate={(v: string) => setView(v as ViewMode)}
/>
)}
{/* ── Pricing ── */}
{view === 'pricing' && (
<div className="max-w-2xl space-y-4">
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Settings className="w-4 h-4 text-brand-400" /> Pricing Config
</h3>
{/* Current values */}
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="px-3 py-2 rounded bg-surface-800/30">
<div className="text-surface-400">Base Price / MB</div>
<div className="text-surface-200 font-medium mt-0.5">{formatWeiToETH(basePrice, 8)} ETH</div>
</div>
<div className="px-3 py-2 rounded bg-surface-800/30">
<div className="text-surface-400">Free Tier</div>
<div className="text-surface-200 font-medium mt-0.5">{formatBytes(freeTier)}</div>
</div>
</div>
{isOwner && (
<>
{/* Set price */}
<div className="space-y-2 pt-3 border-t border-surface-800">
<label className="text-xs text-surface-400">Set Base Price / MB (wei)</label>
<div className="flex gap-2">
<input value={priceInput} onChange={e => setPriceInput(e.target.value)}
placeholder={basePrice.toString()}
className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
/>
<button onClick={() => {
if (!priceInput) return;
const wei = BigInt(priceInput);
doTx('Set price', () => paymentService.adminSetPricePerMB(provider!, wei));
}}
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors">
Update
</button>
</div>
<div className="text-[10px] text-surface-500">
Current: {formatWeiToETH(basePrice, 8)} ETH e.g. 100000000000000 = 0.0001 ETH
</div>
</div>
{/* Set free tier */}
<div className="space-y-2 pt-3 border-t border-surface-800">
<label className="text-xs text-surface-400">Set Free Tier (bytes)</label>
<div className="flex gap-2">
<input value={freeInput} onChange={e => setFreeInput(e.target.value)}
placeholder={freeTier.toString()}
className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
/>
<button onClick={() => {
if (!freeInput) return;
doTx('Set free tier', () => paymentService.adminSetFreeTierBytes(provider!, BigInt(freeInput)));
}}
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors">
Update
</button>
</div>
<div className="text-[10px] text-surface-500">
Current: {formatBytes(freeTier)} 10485760 = 10 MB
</div>
</div>
{/* Withdraw */}
<div className="pt-3 border-t border-surface-800 space-y-2">
<label className="text-xs text-surface-400">Withdraw Funds</label>
<div className="flex flex-wrap gap-2">
{tokens.filter(t => t.enabled).map(tk => {
const bal = contractBalance[tk.symbol];
if (!bal || bal === '0') return null;
return (
<button key={tk.symbol} onClick={() =>
doTx(`Withdraw ${tk.symbol}`, () =>
tk.symbol === 'ETH'
? paymentService.adminWithdrawETH(provider!, address as `0x${string}`)
: paymentService.adminWithdrawToken(provider!, tk.symbol, address as `0x${string}`)
)
}
className="px-3 py-1.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 text-amber-400 text-xs transition-colors">
Withdraw {tk.symbol} ({tk.symbol === 'ETH' ? formatWeiToETH(bal, 4) : bal})
</button>
);
})}
</div>
</div>
</>
)}
</div>
</div>
<PricingView
basePrice={basePrice}
freeTier={freeTier}
isOwner={isOwner}
tokens={tokens}
contractBalance={contractBalance}
priceInput={priceInput}
setPriceInput={setPriceInput}
freeInput={freeInput}
setFreeInput={setFreeInput}
doTx={doTx}
provider={provider}
address={address}
/>
)}
{/* ── Tokens ── */}
{view === 'tokens' && (
<div className="max-w-2xl space-y-4">
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Coins className="w-4 h-4 text-brand-400" /> Supported Tokens
</h3>
{/* Token list */}
<div className="space-y-2">
{tokens.map(tk => (
<div key={tk.symbol} className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/40 text-xs">
<div className="flex items-center gap-3">
<span className={`w-2 h-2 rounded-full ${tk.enabled ? 'bg-accent-green' : 'bg-surface-500'}`} />
<span className="font-medium text-surface-200">{tk.symbol}</span>
<span className="font-mono text-surface-500">{tk.address.slice(0, 10)}...</span>
<span className="text-surface-500">{tk.decimals} decimals</span>
</div>
<span className="text-surface-400">{formatWeiToETH(tk.weiPerToken, 4)} wei/token</span>
</div>
))}
</div>
</div>
{/* Admin: configure token */}
{isOwner && (
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white">Configure Token</h3>
<div className="grid grid-cols-2 gap-3 text-xs">
<input value={tokenSymbol} onChange={e => setTokenSymbol(e.target.value.toUpperCase())}
placeholder="Symbol (e.g. USDC)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
/>
<input value={tokenAddr} onChange={e => setTokenAddr(e.target.value)}
placeholder="Token address (0x...)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
/>
<input value={tokenDec} onChange={e => setTokenDec(e.target.value)}
placeholder="Decimals (e.g. 6)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
/>
<input value={tokenWei} onChange={e => setTokenWei(e.target.value)}
placeholder="weiPerToken (e.g. 500000000000000)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
/>
<label className="flex items-center gap-2 text-surface-400">
<input type="checkbox" checked={tokenEnabled} onChange={e => setTokenEnabled(e.target.checked)}
className="rounded border-surface-600"
/>
Enabled
</label>
<button onClick={() => {
if (!tokenSymbol || !tokenAddr) return;
doTx('Configure token', () => paymentService.adminConfigureToken(
provider!, tokenSymbol, tokenAddr as `0x${string}`, parseInt(tokenDec), BigInt(tokenWei || '0'), tokenEnabled
));
}}
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white font-medium transition-colors">
Apply
</button>
</div>
</div>
)}
{/* Withdraw section */}
{isOwner && Object.keys(contractBalance).length > 0 && (
<div className="glass rounded-xl p-5 space-y-3">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<PiggyBank className="w-4 h-4 text-brand-400" /> Withdraw
</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(contractBalance).filter(([_, bal]) => bal !== '0').map(([sym, bal]) => (
<button key={sym} onClick={() =>
doTx(`Withdraw ${sym}`, () =>
sym === 'ETH'
? paymentService.adminWithdrawETH(provider!, address as `0x${string}`)
: paymentService.adminWithdrawToken(provider!, sym, address as `0x${string}`)
)
}
className="px-3 py-1.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 text-amber-400 text-xs transition-colors">
Withdraw {sym} ({sym === 'ETH' ? formatWeiToETH(bal, 4) : bal})
</button>
))}
{Object.values(contractBalance).every(b => b === '0') && (
<span className="text-xs text-surface-500">No balance to withdraw</span>
)}
</div>
</div>
)}
</div>
<TokensView
tokens={tokens}
isOwner={isOwner}
contractBalance={contractBalance}
tokenSymbol={tokenSymbol}
setTokenSymbol={setTokenSymbol}
tokenAddr={tokenAddr}
setTokenAddr={setTokenAddr}
tokenDec={tokenDec}
setTokenDec={setTokenDec}
tokenWei={tokenWei}
setTokenWei={setTokenWei}
tokenEnabled={tokenEnabled}
setTokenEnabled={setTokenEnabled}
doTx={doTx}
provider={provider}
address={address}
/>
)}
{/* ── Discount Tiers ── */}
{view === 'tiers' && (
<div className="max-w-2xl space-y-4">
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Tags className="w-4 h-4 text-brand-400" /> MAOS Discount Tiers
</h3>
{/* Tier list */}
<div className="space-y-2">
{tiers.map((tier, i) => (
<div key={i} className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/40 text-xs">
<div className="flex items-center gap-3">
<span className="text-surface-200 font-medium">{formatWeiToETH(tier.minBalance, 0)} MAOS</span>
<span className="px-1.5 py-0.5 rounded bg-accent-cyan/10 text-accent-cyan">{tier.discountBps / 100}% off</span>
</div>
{isOwner && (
<button onClick={() => doTx('Remove tier', () => paymentService.adminRemoveDiscountTier(provider!, i))}
className="p-1 rounded hover:bg-accent-rose/10 text-surface-400 hover:text-accent-rose transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
</div>
</div>
{/* Admin: add tier */}
{isOwner && (
<div className="glass rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Plus className="w-4 h-4 text-brand-400" /> Add / Update Tier
</h3>
<div className="flex gap-2 items-end">
<div className="flex-1 space-y-1">
<label className="text-[10px] text-surface-500">Min MAOS balance</label>
<input value={tierBalance} onChange={e => setTierBalance(e.target.value)}
placeholder="e.g. 5000" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-[10px] text-surface-500">Discount %</label>
<input value={tierBps} onChange={e => setTierBps(e.target.value)}
placeholder="e.g. 50" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
/>
</div>
<button onClick={() => {
if (!tierBalance || !tierBps) return;
const bal = BigInt(parseFloat(tierBalance) * 1e18);
const bps = parseInt(tierBps) * 100;
doTx('Set tier', () => paymentService.adminSetDiscountTier(provider!, bal, bps));
}}
className="px-4 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors whitespace-nowrap">
<Plus className="w-3.5 h-3.5 inline mr-1" /> Add
</button>
</div>
</div>
)}
</div>
<TiersView
tiers={tiers}
isOwner={isOwner}
tierBalance={tierBalance}
setTierBalance={setTierBalance}
tierBps={tierBps}
setTierBps={setTierBps}
doTx={doTx}
provider={provider}
/>
)}
</>
)}
@@ -565,5 +307,6 @@ export default function AdminPaymentPage() {
</>
)}
</PortalLayout>
</AuthGuard>
);
}
@@ -0,0 +1,221 @@
import { describe, it, expect } from 'vitest';
/* ── Route matching & Kubo mapping tests ──
*
* These test the pure functions from route.ts in isolation.
* We import the source directly since these functions have no
* external dependencies (no fetch, no cookies, no process.env).
*/
// Inline import — vitest resolves @/ alias via vitest.config.ts
// We need to import the actual functions. Since they're not exported
// (they're module-private), we duplicate the logic here for testing.
// In a real project, consider exporting them for testability.
function matchRoute(path: string[], method: string): { target: string; path: string; method: string } | null {
if (!path || path.length === 0) return null;
const [segment, ...rest] = path;
switch (segment) {
case 'health':
return { target: 'health', path: '/health', method };
case 'auth':
return { target: 'auth', path: '/' + (rest.length > 0 ? rest.join('/') : ''), method };
case 'users':
return { target: 'users', path: '/users' + (rest.length > 0 ? '/' + rest.join('/') : ''), method };
case 'explorer':
case 'ipns':
return { target: 'ipfs', path: '/' + path.join('/'), method };
default:
return { target: 'ipfs', path: '/' + path.join('/'), method };
}
}
function mapToKuboAPI(path: string, method: string): string {
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
switch (p) {
case 'node/info':
return '/api/v0/id';
case 'peers':
return '/api/v0/swarm/peers';
case 'pins':
return method === 'GET' ? '/api/v0/pin/ls' : '/api/v0/pin/add';
default:
if (p.startsWith('pins/')) {
const cid = p.slice(5);
return `/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`;
}
if (p === 'files/upload') {
return '/api/v0/add?pin=true';
}
if (p.startsWith('files/')) {
return '/api/v0/ls';
}
if (p.startsWith('explorer/ls/')) {
const cid = p.slice('explorer/ls/'.length);
return `/api/v0/ls?arg=${encodeURIComponent(cid)}`;
}
if (p.startsWith('explorer/cat/')) {
const cid = p.slice('explorer/cat/'.length);
return `/api/v0/cat?arg=${encodeURIComponent(cid)}`;
}
if (p.startsWith('explorer/stat/')) {
const cid = p.slice('explorer/stat/'.length);
return `/api/v0/object/stat?arg=${encodeURIComponent(cid)}`;
}
if (p.startsWith('explorer/resolve/')) {
const name = p.slice('explorer/resolve/'.length);
return `/api/v0/resolve?arg=${encodeURIComponent(name)}`;
}
if (p === 'ipns/publish') return '/api/v0/name/publish';
if (p === 'ipns/keys') return '/api/v0/key/list';
if (p.startsWith('ipns/resolve/')) {
const name = p.slice('ipns/resolve/'.length);
return `/api/v0/name/resolve?arg=${encodeURIComponent(name)}`;
}
if (p.startsWith('ipns/keys/gen/')) {
const name = p.slice('ipns/keys/gen/'.length);
return `/api/v0/key/gen?arg=${encodeURIComponent(name)}`;
}
if (p === 'repo/stats') return '/api/v0/repo/stat';
if (p === 'bw/stats') return '/api/v0/bw/stats';
return '/api/v0/' + p;
}
}
/* ════════════════════════ matchRoute ════════════════════════ */
describe('matchRoute', () => {
it('returns null for empty path', () => {
expect(matchRoute([], 'GET')).toBeNull();
});
it('routes health', () => {
const r = matchRoute(['health'], 'GET');
expect(r?.target).toBe('health');
expect(r?.path).toBe('/health');
});
it('routes auth', () => {
const r = matchRoute(['auth'], 'GET');
expect(r?.target).toBe('auth');
expect(r?.path).toBe('/');
});
it('routes auth/me', () => {
const r = matchRoute(['auth', 'me'], 'GET');
expect(r?.target).toBe('auth');
expect(r?.path).toBe('/me');
});
it('routes auth/login with POST', () => {
const r = matchRoute(['auth', 'login'], 'POST');
expect(r?.target).toBe('auth');
expect(r?.path).toBe('/login');
expect(r?.method).toBe('POST');
});
it('routes users', () => {
const r = matchRoute(['users'], 'GET');
expect(r?.target).toBe('users');
expect(r?.path).toBe('/users');
});
it('routes users with subpath', () => {
const r = matchRoute(['users', 'create'], 'POST');
expect(r?.target).toBe('users');
expect(r?.path).toBe('/users/create');
});
it('routes explorer to ipfs', () => {
const r = matchRoute(['explorer', 'ls', 'QmTest'], 'GET');
expect(r?.target).toBe('ipfs');
});
it('routes ipns to ipfs', () => {
const r = matchRoute(['ipns', 'publish'], 'POST');
expect(r?.target).toBe('ipfs');
});
it('routes unknown segments to ipfs (catch-all)', () => {
const r = matchRoute(['pins'], 'GET');
expect(r?.target).toBe('ipfs');
expect(r?.path).toBe('/pins');
});
});
/* ════════════════════════ mapToKuboAPI ════════════════════════ */
describe('mapToKuboAPI', () => {
it('maps node/info to /api/v0/id', () => {
expect(mapToKuboAPI('node/info', 'GET')).toBe('/api/v0/id');
});
it('maps peers to /api/v0/swarm/peers', () => {
expect(mapToKuboAPI('peers', 'GET')).toBe('/api/v0/swarm/peers');
});
it('maps pins GET to /api/v0/pin/ls', () => {
expect(mapToKuboAPI('pins', 'GET')).toBe('/api/v0/pin/ls');
});
it('maps pins POST to /api/v0/pin/add', () => {
expect(mapToKuboAPI('pins', 'POST')).toBe('/api/v0/pin/add');
});
it('maps pins/rm to rm with CID', () => {
expect(mapToKuboAPI('pins/QmTest123', 'DELETE')).toBe('/api/v0/pin/rm?arg=QmTest123');
});
it('maps files/upload to add with pin', () => {
expect(mapToKuboAPI('files/upload', 'POST')).toBe('/api/v0/add?pin=true');
});
it('maps files/list to ls', () => {
expect(mapToKuboAPI('files/list', 'GET')).toBe('/api/v0/ls');
});
it('maps explorer/ls/<cid>', () => {
expect(mapToKuboAPI('explorer/ls/QmTest', 'GET')).toBe('/api/v0/ls?arg=QmTest');
});
it('maps explorer/cat/<cid>', () => {
expect(mapToKuboAPI('explorer/cat/QmTest', 'GET')).toBe('/api/v0/cat?arg=QmTest');
});
it('maps explorer/stat/<cid>', () => {
expect(mapToKuboAPI('explorer/stat/QmTest', 'GET')).toBe('/api/v0/object/stat?arg=QmTest');
});
it('maps explorer/resolve/<name>', () => {
expect(mapToKuboAPI('explorer/resolve/example', 'GET')).toBe('/api/v0/resolve?arg=example');
});
it('maps ipns/publish', () => {
expect(mapToKuboAPI('ipns/publish', 'POST')).toBe('/api/v0/name/publish');
});
it('maps ipns/keys', () => {
expect(mapToKuboAPI('ipns/keys', 'GET')).toBe('/api/v0/key/list');
});
it('maps ipns/resolve/<name>', () => {
expect(mapToKuboAPI('ipns/resolve/k51...', 'GET')).toBe('/api/v0/name/resolve?arg=k51...');
});
it('maps ipns/keys/gen/<name>', () => {
expect(mapToKuboAPI('ipns/keys/gen/mykey', 'GET')).toBe('/api/v0/key/gen?arg=mykey');
});
it('maps repo/stats', () => {
expect(mapToKuboAPI('repo/stats', 'GET')).toBe('/api/v0/repo/stat');
});
it('maps bw/stats', () => {
expect(mapToKuboAPI('bw/stats', 'GET')).toBe('/api/v0/bw/stats');
});
it('falls through for unknown paths', () => {
expect(mapToKuboAPI('version', 'GET')).toBe('/api/v0/version');
});
});
+39 -21
View File
@@ -1,19 +1,17 @@
/* ── IPFS Portal API Proxy ──
*
* Catch-all /api/* handler.
* Catch-all /api/* handler (exclusief /api/auth/* — die hebben eigen route files).
* Routes incoming calls to the appropriate backend:
* /api/users* → Python User Management API (port 8444)
* /api/node/* → Python User Management API (port 8444)
* /api/portal/* → Python User Management API
* /api/health → inline health check
* /api/pins* → IPFS Kubo API (via nginx proxy in production)
* /api/node/* → IPFS Kubo API
* /api/peers → IPFS Kubo API
* /api/files/* → IPFS Kubo API
* /api/* → IPFS Kubo API
*
* In dev (localhost), this file proxies to the remote server.
* In production, nginx handles the backend routing directly.
* Leest X-Session-Token uit JWT cookie voor authenticated requests.
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
export const dynamic = 'force-dynamic';
@@ -25,7 +23,7 @@ const ADMIN_KEY = process.env.USER_API_ADMIN_KEY || 'maos-admin-2024';
/* ── Route matching ── */
interface RouteMatch {
target: 'users' | 'ipfs' | 'health';
target: 'user' | 'ipfs' | 'health';
path: string; // path relative to the backend
method: string;
}
@@ -39,15 +37,20 @@ function matchRoute(path: string[], method: string): RouteMatch | null {
case 'health':
return { target: 'health', path: '/health', method };
case 'node':
// /api/node/info → Python backend /node/info
return { target: 'user', path: '/' + path.join('/'), method };
case 'portal':
// /api/portal/users → Python backend /users, /api/portal/pins → /pins, etc.
return { target: 'user', path: '/' + rest.join('/'), method };
case 'users':
return { target: 'users', path: '/users' + (rest.length > 0 ? '/' + rest.join('/') : ''), method };
// Legacy /api/users* → Python backend /users*
return { target: 'user', path: '/users' + (rest.length > 0 ? '/' + rest.join('/') : ''), method };
case 'explorer':
// /explorer/ls/<cid> → IPFS Kubo with full path for mapToKuboAPI
return { target: 'ipfs', path: '/' + path.join('/'), method };
case 'ipns':
// /ipns/publish, /ipns/keys, etc.
return { target: 'ipfs', path: '/' + path.join('/'), method };
default:
@@ -55,9 +58,18 @@ function matchRoute(path: string[], method: string): RouteMatch | null {
}
}
/* ── Extract session token from JWT cookie ── */
async function getSessionToken(req: NextRequest): Promise<string | null> {
const token = req.cookies.get(SESSION_COOKIE)?.value;
if (!token) return null;
const session = await verifySessionJWT(token);
return session?.sessionToken ?? null;
}
/* ── User API proxy ── */
async function proxyUserAPI(path: string, method: string, body: ReadableStream<Uint8Array> | null): Promise<Response> {
async function proxyUserAPI(path: string, method: string, body: ReadableStream<Uint8Array> | null, req: NextRequest): Promise<Response> {
const url = `${USER_API_BASE}${path}`;
const headers: Record<string, string> = {
'X-Admin-Key': ADMIN_KEY,
@@ -67,6 +79,12 @@ async function proxyUserAPI(path: string, method: string, body: ReadableStream<U
headers['Content-Type'] = 'application/json';
}
// Forward session token from JWT cookie to Python backend
const sessionToken = await getSessionToken(req);
if (sessionToken) {
headers['X-Session-Token'] = sessionToken;
}
return fetch(url, {
method,
headers,
@@ -222,8 +240,8 @@ export async function GET(
switch (route.target) {
case 'health':
return handleHealth(req);
case 'users':
return proxyResult(await proxyUserAPI(route.path, 'GET', null));
case 'user':
return proxyResult(await proxyUserAPI(route.path, 'GET', null, req));
case 'ipfs':
return proxyIPFS(route.path, 'GET', req);
}
@@ -238,8 +256,8 @@ export async function POST(
if (!route) return NextResponse.json({ error: 'Not found' }, { status: 404 });
switch (route.target) {
case 'users':
return proxyResult(await proxyUserAPI(route.path, 'POST', req.body));
case 'user':
return proxyResult(await proxyUserAPI(route.path, 'POST', req.body, req));
case 'ipfs':
return proxyIPFS(route.path, 'POST', req);
default:
@@ -256,8 +274,8 @@ export async function DELETE(
if (!route) return NextResponse.json({ error: 'Not found' }, { status: 404 });
switch (route.target) {
case 'users':
return proxyResult(await proxyUserAPI(route.path, 'DELETE', null));
case 'user':
return proxyResult(await proxyUserAPI(route.path, 'DELETE', null, req));
case 'ipfs':
return proxyIPFS(route.path, 'DELETE', req);
default:
+37
View File
@@ -0,0 +1,37 @@
/* ── POST /api/auth/challenge ──
*
* Proxied naar Python User API /auth/challenge.
* Geeft nonce + message terug voor SIWE signing.
*/
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { address } = body;
if (!address || typeof address !== 'string') {
return NextResponse.json({ error: 'Address required' }, { status: 400 });
}
const res = await fetch(`${USER_API_BASE}/auth/challenge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: address.toLowerCase() }),
signal: AbortSignal.timeout(5000),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch (e: any) {
return NextResponse.json(
{ error: e.message || 'Challenge failed' },
{ status: 500 },
);
}
}
+85
View File
@@ -0,0 +1,85 @@
/* ── POST /api/auth/login ──
*
* SIWE wallet login: verifieert signed message via Python backend,
* maakt vervolgens JWT aan met session_token voor Next.js middleware.
*/
import { NextRequest, NextResponse } from 'next/server';
import { createSessionJWT, cookieOptions } from '@/lib/auth-server';
export const dynamic = 'force-dynamic';
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { address, signature, nonce } = body;
if (!address || !signature || !nonce) {
return NextResponse.json(
{ error: 'address, signature, and nonce required' },
{ status: 400 },
);
}
// Proxy login to Python User API
const res = await fetch(`${USER_API_BASE}/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 });
}
// 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';
try {
const verifyRes = await fetch(`${USER_API_BASE}/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';
}
} catch {
// Non-critical — default to 'user'
}
// Create JWT containing the Python session token
const jwt = await createSessionJWT({
address: userAddress,
role,
sessionToken: pythonSessionToken,
});
const response = NextResponse.json({
authenticated: true,
address: userAddress,
role,
});
response.cookies.set('ipfs-portal-session', jwt, cookieOptions());
return response;
} catch (e: any) {
return NextResponse.json(
{ error: e.message || 'Login failed' },
{ status: 500 },
);
}
}
+47
View File
@@ -0,0 +1,47 @@
/* ── POST /api/auth/logout ──
*
* Logt uit bij Python backend en wist JWT cookie.
*/
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
export const dynamic = 'force-dynamic';
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
export async function POST() {
const cookieStore = await cookies();
const jwt = cookieStore.get(SESSION_COOKIE)?.value;
let sessionToken: string | undefined;
// Extract session_token from JWT
if (jwt) {
const payload = await verifySessionJWT(jwt);
if (payload) {
sessionToken = payload.sessionToken;
}
}
// Notify Python backend (best effort)
if (sessionToken) {
try {
await fetch(`${USER_API_BASE}/auth/logout`, {
method: 'POST',
headers: { 'X-Session-Token': sessionToken },
signal: AbortSignal.timeout(3000),
});
} catch {
// Non-critical
}
}
const response = NextResponse.json({ authenticated: false });
response.cookies.set(SESSION_COOKIE, '', {
httpOnly: true,
maxAge: 0,
path: '/',
});
return response;
}
+31
View File
@@ -0,0 +1,31 @@
/* ── GET /api/auth/me ──
*
* Checkt huidige sessie op basis van httpOnly cookie.
* Geeft wallet address en role terug.
*/
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
export const dynamic = 'force-dynamic';
export async function GET() {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) {
return NextResponse.json({ authenticated: false }, { status: 401 });
}
const session = await verifySessionJWT(token);
if (!session) {
return NextResponse.json({ authenticated: false }, { status: 401 });
}
return NextResponse.json({
authenticated: true,
address: session.address,
role: session.role,
});
}
+58
View File
@@ -0,0 +1,58 @@
/* ── POST /api/auth/password — Change password ──
*
* Proxies naar Python User API voor wachtwoord wijzigen.
*/
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
const ADMIN_KEY = process.env.USER_API_ADMIN_KEY || 'maos-admin-2024';
export const dynamic = 'force-dynamic';
export async function POST(req: NextRequest) {
// Verify session
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const session = await verifySessionJWT(token);
if (!session) {
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
}
try {
const body = await req.json();
const { currentPassword, newPassword } = body;
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Current and new password required' }, { status: 400 });
}
const res = await fetch(`${USER_API_BASE}/auth/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Admin-Key': ADMIN_KEY },
body: JSON.stringify({
address: session.address,
currentPassword,
newPassword,
}),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return NextResponse.json(
{ error: data.error || 'Password change failed' },
{ status: res.status },
);
}
return NextResponse.json({ success: true });
} catch (e: any) {
return NextResponse.json({ error: e.message || 'Password change failed' }, { status: 500 });
}
}
+153
View File
@@ -0,0 +1,153 @@
/* ── SSE (Server-Sent Events) endpoint ──
*
* Pusht live updates naar de dashboard/client:
* - Bandwidth stats (totalIn, totalOut, rateIn, rateOut)
* - Peer count
* - Repo size (periodiek)
*
* Next.js route handlers ondersteunen ReadableStream voor SSE.
* Alternatief voor WebSocket (werkt door proxy heen).
*/
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
const KUBO_API = process.env.KUBO_API_URL || process.env.NEXT_PUBLIC_KUBO_API_URL;
const KUBO_AUTH = process.env.KUBO_BASIC_AUTH;
const POLL_INTERVAL = 3_000; // 3 seconden
/* ── Helpers ── */
function encodeSSE(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
async function kuboFetch(path: string): Promise<any> {
if (!KUBO_API) return null;
try {
const url = new URL(KUBO_API);
url.pathname = path;
const headers: Record<string, string> = {};
if (KUBO_AUTH) {
headers['Authorization'] = 'Basic ' + Buffer.from(KUBO_AUTH).toString('base64');
}
const res = await fetch(url.toString(), {
method: 'POST',
headers,
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
async function fetchBWStats() {
const raw = await kuboFetch('/api/v0/bw/stats');
if (!raw) return null;
return {
totalIn: raw.TotalIn ?? 0,
totalOut: raw.TotalOut ?? 0,
rateIn: raw.RateIn ?? 0,
rateOut: raw.RateOut ?? 0,
};
}
async function fetchPeerCount() {
const raw = await kuboFetch('/api/v0/swarm/peers');
if (!raw || !raw.Peers) return 0;
return raw.Peers.length;
}
async function fetchRepoStats() {
const raw = await kuboFetch('/api/v0/repo/stat');
if (!raw) return null;
return {
repoSize: raw.RepoSize ?? 0,
storageMax: raw.StorageMax ?? 0,
numObjects: raw.NumObjects ?? 0,
};
}
/* ── GET handler — SSE stream ── */
export async function GET(req: Request): Promise<Response> {
const abortController = new AbortController();
const signal = abortController.signal;
const stream = new ReadableStream({
async start(controller) {
// Send initial connection event
controller.enqueue(new TextEncoder().encode(encodeSSE('connected', { ts: Date.now() })));
let bwPrev = { totalIn: 0, totalOut: 0 };
let lastRepoPoll = 0;
const interval = setInterval(async () => {
if (signal.aborted) {
clearInterval(interval);
return;
}
try {
// Bandwidth (elke poll)
const bw = await fetchBWStats();
if (bw) {
const deltaIn = bw.totalIn - bwPrev.totalIn;
const deltaOut = bw.totalOut - bwPrev.totalOut;
bwPrev = { totalIn: bw.totalIn, totalOut: bw.totalOut };
controller.enqueue(
new TextEncoder().encode(encodeSSE('bandwidth', {
totalIn: bw.totalIn,
totalOut: bw.totalOut,
rateIn: bw.rateIn,
rateOut: bw.rateOut,
deltaIn: Math.max(0, deltaIn),
deltaOut: Math.max(0, deltaOut),
}))
);
}
// Peer count (elke poll)
const peerCount = await fetchPeerCount();
controller.enqueue(
new TextEncoder().encode(encodeSSE('peers', { count: peerCount }))
);
// Repo stats (elke 15s)
const now = Date.now();
if (now - lastRepoPoll >= 15_000) {
lastRepoPoll = now;
const repo = await fetchRepoStats();
if (repo) {
controller.enqueue(
new TextEncoder().encode(encodeSSE('repo', repo))
);
}
}
} catch {
// Silently continue — connection blijft open
}
}, POLL_INTERVAL);
// Cleanup on abort
signal.addEventListener('abort', () => {
clearInterval(interval);
});
},
cancel() {
abortController.abort();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}
@@ -0,0 +1,54 @@
'use client';
/* ════════════════════════════ FreeTierProgress ════════════════════════════ */
interface FreeTierProgressProps {
used: number;
max: number;
label?: string;
}
export default function FreeTierProgress({
used,
max,
label = 'Free uploads vandaag',
}: FreeTierProgressProps) {
const pct = max > 0 ? Math.min((used / max) * 100, 100) : 0;
const remaining = Math.max(0, max - used);
const color =
remaining === 0
? 'bg-accent-rose'
: remaining < 5
? 'bg-accent-amber'
: 'bg-accent-green';
return (
<div className="glass rounded-xl p-5">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-surface-400">{label}</span>
<span className={`text-xs font-mono font-medium ${
remaining === 0 ? 'text-accent-rose' : 'text-accent-green'
}`}>
{remaining} / {max}
</span>
</div>
<div className="h-2.5 rounded-full bg-surface-700 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-700 ease-out ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex justify-between mt-1.5">
<span className="text-[10px] text-surface-500">
{used} gebruikt vandaag
</span>
{remaining <= 3 && remaining > 0 && (
<span className="text-[10px] text-accent-amber">Bijna op</span>
)}
{remaining === 0 && (
<span className="text-[10px] text-accent-rose">Limiet bereikt</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,43 @@
'use client';
/* ════════════════════════════ StorageGauge ════════════════════════════ */
interface StorageGaugeProps {
usedGB: number;
maxGB: number;
label?: string;
}
export default function StorageGauge({ usedGB, maxGB, label = 'Storage' }: StorageGaugeProps) {
const pct = maxGB > 0 ? Math.min((usedGB / maxGB) * 100, 100) : 0;
const color =
pct > 90 ? 'bg-accent-rose' : pct > 70 ? 'bg-accent-amber' : 'bg-accent-cyan';
const textColor =
pct > 90 ? 'text-accent-rose' : pct > 70 ? '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">{label}</span>
<span className={`text-xs font-mono font-medium ${textColor}`}>
{usedGB.toFixed(1)} / {maxGB.toFixed(1)} GB
</span>
</div>
<div className="h-2.5 rounded-full bg-surface-700 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-700 ease-out ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex justify-between mt-1.5">
<span className="text-[10px] text-surface-500">{pct.toFixed(0)}% gebruikt</span>
{pct > 80 && (
<span className="text-[10px] text-accent-rose flex items-center gap-1">
Bijna vol
</span>
)}
</div>
</div>
);
}
+57 -47
View File
@@ -1,13 +1,12 @@
'use client';
import { useEffect, useState } from 'react';
import { getPeers, listPins, checkHealth, getRepoStats, getBWStats } from '@/lib/api';
import type { RepoStats, BWStats } from '@/lib/api';
import PortalLayout from '@/app/layout-portal';
import Link from 'next/link';
import { SkeletonCard, SkeletonRow } from '@/components/Skeleton';
import { useDashboard } from '@/lib/swr';
import { useRealtime } from '@/lib/useRealtime';
import {
HardDrive,
Globe,
Wifi,
Database,
Server,
@@ -17,49 +16,42 @@ import {
Fingerprint,
} from 'lucide-react';
interface PeerData { id: string; addr: string; latency: string }
interface PinData { cid: string; name: string; size: number }
export default function DashboardPage() {
const [peers, setPeers] = useState<PeerData[]>([]);
const [pins, setPins] = useState<PinData[]>([]);
const [health, setHealth] = useState<{ status: string; node: string } | null>(null);
const [repo, setRepo] = useState<RepoStats | null>(null);
const [bw, setBW] = useState<BWStats | null>(null);
const [loading, setLoading] = useState(true);
const { data, isValidating } = useDashboard();
const realtime = useRealtime();
useEffect(() => {
async function load() {
try {
const [h, p, pinList, r, b] = await Promise.all([
checkHealth().catch(() => null),
getPeers().catch(() => [] as PeerData[]),
listPins().catch(() => [] as PinData[]),
getRepoStats().catch(() => null),
getBWStats().catch(() => null),
]);
setHealth(h);
setPeers(p);
setPins(pinList);
setRepo(r);
setBW(b);
} finally {
setLoading(false);
// Use SWR data, fall back to SSE live data where available
const peers = data?.peers ?? [];
const pins = data?.pins ?? [];
const health = data?.health;
const repo = data?.repo;
// Bandwidth: prefer SSE live data, fallback to SWR
const bwLive = realtime.bandwidth;
const bw = bwLive
? {
totalIn: bwLive.totalIn,
totalOut: bwLive.totalOut,
rateIn: bwLive.rateIn,
rateOut: bwLive.rateOut,
}
}
load();
const iv = setInterval(load, 15000);
return () => clearInterval(iv);
}, []);
: data?.bw;
const loading = isValidating && !data;
const repoSizeGb = repo ? (repo.repoSize / 1e9).toFixed(2) : '—';
const bwIn = bw ? (bw.totalIn / 1e6).toFixed(1) : '—';
const bwOut = bw ? (bw.totalOut / 1e6).toFixed(1) : '—';
// Peer count from SSE (live) or SWR
const peerCount = realtime.peers?.count ?? peers.length;
// Pin count from SWR
const pinCount = pins.length;
const stats = [
{ label: 'Peers', value: peers.length, icon: Wifi, color: 'text-accent-cyan', bg: 'bg-accent-cyan/10' },
{ label: 'Pins', value: pins.length, icon: HardDrive, color: 'text-accent-purple', bg: 'bg-accent-purple/10' },
{ label: 'Node', value: health?.status ?? '—', icon: Server, color: 'text-accent-green', bg: 'bg-accent-green/10' },
{ label: 'Peers', value: peerCount, icon: Wifi, color: 'text-accent-cyan', bg: 'bg-accent-cyan/10' },
{ label: 'Pins', value: pinCount, icon: HardDrive, color: 'text-accent-purple', bg: 'bg-accent-purple/10' },
{ label: 'Node', value: health?.status ?? (realtime.connected ? 'connected' : '—'), icon: Server, color: 'text-accent-green', bg: 'bg-accent-green/10' },
{ label: 'Repo', value: `${repoSizeGb} GB`, icon: Database, color: 'text-accent-amber', bg: 'bg-accent-amber/10' },
];
@@ -71,17 +63,33 @@ export default function DashboardPage() {
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="text-sm text-surface-400 mt-1">IPFS node overview, storage, and bandwidth</p>
</div>
{loading && (
<div className="flex items-center gap-2 text-xs text-surface-500">
<div className="w-3 h-3 rounded-full border border-surface-600 border-t-transparent animate-spin" />
refreshing
</div>
)}
<div className="flex items-center gap-3">
{realtime.connected && (
<span className="flex items-center gap-1.5 text-xs text-accent-green">
<span className="w-1.5 h-1.5 rounded-full bg-accent-green animate-pulse" />
Live
</span>
)}
{!realtime.connected && (
<span className="flex items-center gap-1.5 text-xs text-surface-500">
<span className="w-1.5 h-1.5 rounded-full bg-surface-600" />
Polling
</span>
)}
{loading && (
<div className="flex items-center gap-2 text-xs text-surface-500">
<div className="w-3 h-3 rounded-full border border-surface-600 border-t-transparent animate-spin" />
loading
</div>
)}
</div>
</div>
{/* Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{stats.map((s) => (
{loading
? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)
: stats.map((s) => (
<div key={s.label} className="glass rounded-xl p-5 animate-fade-in">
<div className="flex items-center justify-between mb-3">
<div className={`p-2 rounded-lg ${s.bg}`}>
@@ -101,9 +109,11 @@ export default function DashboardPage() {
<div className="glass rounded-xl p-5 animate-fade-in">
<h2 className="text-sm font-semibold text-white mb-4 flex items-center justify-between">
<span className="flex items-center gap-2"><Wifi className="w-4 h-4 text-accent-cyan" />Connected Peers</span>
<span className="text-xs text-surface-500">{peers.length} peer{peers.length !== 1 ? 's' : ''}</span>
<span className="text-xs text-surface-500">{peerCount} peer{peerCount !== 1 ? 's' : ''}</span>
</h2>
{peers.length === 0 ? (
{loading
? Array.from({ length: 5 }).map((_, i) => <SkeletonRow key={i} cols={2} />)
: peers.length === 0 ? (
<p className="text-sm text-surface-500">No peers connected</p>
) : (
<div className="space-y-1.5 max-h-56 overflow-y-auto">
+38
View File
@@ -0,0 +1,38 @@
'use client';
/* ════════════════════════════ Error (Next.js root) ════════════════════════════
* Next.js error boundary voor root layout crashes.
*/
import { AlertCircle, RefreshCw } from 'lucide-react';
interface Props {
error: Error & { digest?: string };
reset: () => void;
}
export default function RootError({ error, reset }: Props) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#0a0e17] p-4">
<div className="glass rounded-xl p-10 text-center max-w-md">
<AlertCircle className="w-12 h-12 text-accent-rose mx-auto mb-4" />
<h1 className="text-xl font-bold text-white mb-2">Er ging iets mis</h1>
<p className="text-sm text-surface-400 mb-2">
De applicatie kon niet laden. Probeer het opnieuw.
</p>
{error.digest && (
<p className="text-[10px] text-surface-600 font-mono mb-4">
Error ID: {error.digest}
</p>
)}
<button
onClick={reset}
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 text-white text-sm font-medium transition-colors"
>
<RefreshCw className="w-4 h-4" />
Opnieuw laden
</button>
</div>
</div>
);
}
@@ -2,7 +2,9 @@
import type { IPFSEntry } from '@/lib/api';
import FileIcon from './FileIcon';
import { CheckCircle } from 'lucide-react';
import { CheckCircle, Download } from 'lucide-react';
import { truncateCid } from '@/lib/helpers';
import { downloadFile } from '@/lib/download';
interface DirectoryListingProps {
entries: IPFSEntry[];
@@ -20,11 +22,6 @@ function formatSize(bytes: number): string {
return `${s} ${units[i] ?? ''}`;
}
function truncateHash(hash: string): string {
if (hash.length <= 12) return hash;
return `${hash.slice(0, 6)}${hash.slice(-4)}`;
}
function SkeletonRow() {
return (
<div className="flex items-center gap-3 px-5 py-3 animate-pulse">
@@ -82,7 +79,7 @@ export default function DirectoryListing({
<div className="w-12 text-center">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-10 text-center" />
<div className="w-20 text-center" />
</div>
<div className="divide-y divide-surface-800/50">
@@ -113,10 +110,23 @@ export default function DirectoryListing({
</span>
<code className="text-xs text-surface-500 w-20 text-right shrink-0 hidden lg:block font-mono">
{truncateHash(entry.hash)}
{truncateCid(entry.hash, 6)}
</code>
<div className="w-10 flex justify-center shrink-0">
<div className="w-20 flex items-center justify-center gap-1 shrink-0">
{entry.type === 'file' && (
<button
onClick={(e) => {
e.stopPropagation();
downloadFile(entry.hash, entry.name);
}}
className="p-1 rounded-md hover:bg-surface-700/50 text-surface-500 hover:text-accent-blue transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-blue"
aria-label={`Download ${entry.name}`}
title={`Download ${entry.name}`}
>
<Download className="w-3.5 h-3.5" />
</button>
)}
{isPinned && (
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
)}
+10 -1
View File
@@ -19,8 +19,17 @@ function detectFileType(filename: string | undefined): 'image' | 'markdown' | 'j
return 'other';
}
function renderMarkdown(text: string): string {
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function renderMarkdown(text: string): string {
return escapeHtml(text)
.replace(/^### (.+)$/gm, '<h3 class="text-white font-semibold text-sm mt-3 mb-1">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="text-white font-semibold text-base mt-4 mb-1">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 class="text-white font-bold text-lg mt-4 mb-2">$1</h1>')
+67 -2
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import PortalLayout from '@/app/layout-portal';
import { explorerLs, explorerCat } from '@/lib/api';
import { explorerLs, explorerCat, listPins } from '@/lib/api';
import type { IPFSEntry } from '@/lib/api';
import CIDInput from './components/CIDInput';
import DirectoryListing from './components/DirectoryListing';
@@ -10,7 +10,10 @@ import FilePreview from './components/FilePreview';
import Breadcrumbs from './components/Breadcrumbs';
import PinBadge from './components/PinBadge';
import GatewayLink from './components/GatewayLink';
import { Search, AlertCircle, RefreshCw } from 'lucide-react';
import SearchBar from '@/components/SearchBar';
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';
const RECENT_STORAGE_KEY = 'ipfs-explorer-recent-cids';
@@ -52,6 +55,8 @@ export default function ExplorerPage() {
const [error, setError] = useState('');
const [pins, setPins] = useState<string[]>([]);
const [recentCids] = useState<string[]>(loadRecentCids);
const [indexStats, setIndexStats] = useState(getIndexStats);
const [rebuilding, setRebuilding] = useState(false);
// Load pins from the pins page (shared localStorage or just track in-memory)
function handlePinToggle(cid: string) {
@@ -122,6 +127,46 @@ export default function ExplorerPage() {
if (cid) navigateTo(cid);
}
/* ── Search ── */
function handleSearchResult(result: SearchResult) {
navigateTo(result.entry.cid);
}
async function rebuildSearchIndex() {
setRebuilding(true);
try {
const pinsList = await listPins();
clearIndex();
let indexed = 0;
let failed = 0;
for (const pin of pinsList) {
if (isTextFile(pin.name) || !pin.name) {
try {
const text = await explorerCat(pin.cid);
const extracted = extractText(text);
if (extracted.length > 0) {
addToIndex({
cid: pin.cid,
name: pin.name || pin.cid.slice(0, 20),
type: 'file',
text: extracted,
size: text.length,
indexedAt: Date.now(),
});
indexed++;
}
} catch {
failed++;
}
}
}
setIndexStats({ entries: indexed, totalChars: 0, lastIndexed: Date.now() });
} finally {
setRebuilding(false);
}
}
const isPinned = previewCid ? pins.includes(previewCid) : false;
return (
@@ -134,6 +179,26 @@ export default function ExplorerPage() {
</p>
</div>
{/* Search + Index rebuild */}
<div className="mb-4 flex items-start gap-3">
<div className="flex-1">
<SearchBar onSelect={handleSearchResult} placeholder="Search indexed files…" />
</div>
<button
onClick={rebuildSearchIndex}
disabled={rebuilding}
className="flex items-center gap-1.5 px-3 py-2.5 rounded-xl bg-surface-900 border border-surface-700 text-xs text-surface-400 hover:text-surface-200 hover:border-surface-600 transition-colors disabled:opacity-50 shrink-0"
title={`${indexStats.entries} entries indexed`}
>
{rebuilding ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Database className="w-3.5 h-3.5" />
)}
<span className="hidden sm:inline">Index</span>
</button>
</div>
{/* CID input */}
<div className="mb-6">
<CIDInput onNavigate={navigateTo} />
+50
View File
@@ -47,6 +47,50 @@ body {
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
/* ── Light mode overrides ── */
.light body,
.light {
--color-surface-950: #f8fafc;
--color-surface-900: #f1f5f9;
--color-surface-850: #e2e8f0;
--color-surface-800: #cbd5e1;
--color-surface-700: #94a3b8;
--color-surface-600: #64748b;
--color-surface-500: #475569;
--color-surface-400: #334155;
--color-surface-300: #1e293b;
--color-surface-200: #0f172a;
--color-surface-50: #020617;
}
.light body {
background-color: #f8fafc;
color: #1e293b;
}
.light .glass {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(0, 0, 0, 0.08);
}
.light .glass-hover:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(6, 182, 212, 0.25);
}
/* ── Transition for theme switch ──
* Applied via .theme-transitioning on <html> during toggle only.
*/
html.theme-transitioning,
html.theme-transitioning *,
html.theme-transitioning *::before,
html.theme-transitioning *::after {
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}
/* ── Focus-visible: toon focus ring alleen bij keyboard nav ── */
*:focus-visible {
outline: 2px solid var(--color-brand-500);
outline-offset: 2px;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
@@ -86,5 +130,11 @@ body {
50% { box-shadow: 0 0 12px rgba(34, 211, 238, 0.6); }
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(16px) translateX(-50%); }
to { opacity: 1; transform: translateY(0) translateX(-50%); }
}
.animate-fade-in { animation: fade-in 0.4s ease-out both; }
.animate-pulse-glow { animation: pulse-glow 2s ease-in-out infinite; }
.animate-slide-up { animation: slide-up 0.3s ease-out both; }
+115 -27
View File
@@ -1,29 +1,20 @@
'use client';
import { useEffect, useState, useMemo } from 'react';
import { getHistory, clearHistory, getSettings, type UploadRecord } from '@/lib/storage';
import { getHistory, clearHistory, removeMultipleFromHistory, type UploadRecord } from '@/lib/storage';
import { formatBytes, gatewayLink, truncateCid } from '@/lib/helpers';
import { downloadFile } from '@/lib/download';
import { useNotify } from '@/lib/notifications';
import { SkeletonTable } from '@/components/Skeleton';
import BatchBar from '@/components/BatchBar';
import PortalLayout from '@/app/layout-portal';
import Link from 'next/link';
import {
Clock, HardDrive, Copy, ExternalLink, Trash2, Search, Upload,
Database, Calendar, Filter, X, CheckCircle, Link as LinkIcon,
Database, Calendar, Filter, X, CheckCircle, Download,
Link as LinkIcon,
} from 'lucide-react';
/* ── Helpers ── */
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const val = bytes / Math.pow(1024, i);
return val < 10 ? val.toFixed(1) + ' ' + units[i] : Math.round(val) + ' ' + units[i];
}
function truncateCid(cid: string): string {
if (cid.length <= 18) return cid;
return cid.slice(0, 12) + '…' + cid.slice(-4);
}
function getMethodBadge(method: UploadRecord['method']): { bg: string; text: string; label: string } {
switch (method) {
case 'free': return { bg: 'bg-accent-green/10', text: 'text-accent-green', label: 'free' };
@@ -39,9 +30,14 @@ export default function HistoryPage() {
const [history, setHistory] = useState<UploadRecord[]>([]);
const [search, setSearch] = useState('');
const [copied, setCopied] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const recordKey = (r: UploadRecord) => `${r.cid}||${r.date}`;
function load() {
setHistory(getHistory());
setLoading(false);
}
useEffect(() => { load(); }, []);
@@ -79,13 +75,41 @@ export default function HistoryPage() {
} catch { /* ignore */ }
}
/* ── Gateway URL ── */
const gatewayUrl = useMemo(() => {
try { return getSettings().gatewayUrl; }
catch { return 'https://maos.dedyn.io/ipfs'; }
}, []);
/* ── Selection ── */
function toggleSelection(key: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
const gatewayLink = (cid: string) => `${gatewayUrl}/${cid}`;
function toggleAll() {
if (selected.size === filtered.length) {
setSelected(new Set());
} else {
setSelected(new Set(filtered.map(recordKey)));
}
}
/* ── Batch delete ── */
const { notify } = useNotify();
function handleBatchDelete() {
const count = selected.size;
if (!confirm(`Delete ${count} upload${count !== 1 ? 's' : ''} from history?`)) return;
const keys = Array.from(selected).map((key) => {
const idx = key.lastIndexOf('||');
return { cid: key.slice(0, idx), date: key.slice(idx + 2) };
});
removeMultipleFromHistory(keys);
setHistory((prev) => prev.filter((r) => !selected.has(recordKey(r))));
setSelected(new Set());
notify({ type: 'success', title: `Deleted ${count} upload${count !== 1 ? 's' : ''}` });
}
/* ── Render ── */
return (
@@ -133,8 +157,17 @@ export default function HistoryPage() {
</div>
)}
{/* ── Loading skeleton ── */}
{loading && (
<div className="glass rounded-xl overflow-hidden">
<div className="px-1">
<SkeletonTable rows={8} cols={3} />
</div>
</div>
)}
{/* ── Empty state ── */}
{history.length === 0 && (
{!loading && history.length === 0 && (
<div className="glass rounded-xl p-12 text-center animate-fade-in">
<div className="flex justify-center mb-4">
<div className="p-3 rounded-xl bg-surface-800/50">
@@ -163,6 +196,14 @@ export default function HistoryPage() {
<table className="w-full">
<thead>
<tr className="border-b border-surface-800 text-left text-xs font-medium text-surface-500 uppercase tracking-wider">
<th className="px-5 py-3 w-10">
<input
type="checkbox"
checked={selected.size === filtered.length && filtered.length > 0}
onChange={toggleAll}
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500"
/>
</th>
<th className="px-5 py-3">File Name</th>
<th className="px-5 py-3">CID</th>
<th className="px-5 py-3">Size</th>
@@ -177,6 +218,15 @@ export default function HistoryPage() {
const gwLink = gatewayLink(rec.cid);
return (
<tr key={rec.cid + rec.date} className="hover:bg-surface-800/30 transition-colors">
{/* Checkbox */}
<td className="px-5 py-3">
<input
type="checkbox"
checked={selected.has(recordKey(rec))}
onChange={() => toggleSelection(recordKey(rec))}
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500"
/>
</td>
{/* File name */}
<td className="px-5 py-3">
<div className="flex items-center gap-2">
@@ -246,6 +296,15 @@ export default function HistoryPage() {
)}
</button>
{/* Download */}
<button
onClick={() => downloadFile(rec.cid, rec.name).catch(() => {})}
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
title="Download file"
>
<Download className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
</button>
{/* Open in gateway */}
<a
href={gwLink}
@@ -272,9 +331,15 @@ export default function HistoryPage() {
const gwLink = gatewayLink(rec.cid);
return (
<div key={rec.cid + rec.date} className="glass rounded-xl p-4 space-y-3">
{/* Name + method badge */}
{/* Checkbox + Name + method badge */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<input
type="checkbox"
checked={selected.has(recordKey(rec))}
onChange={() => toggleSelection(recordKey(rec))}
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500 shrink-0 mt-0.5"
/>
<HardDrive className="w-4 h-4 text-surface-500 shrink-0" />
<span className="text-sm text-surface-200 truncate" title={rec.name}>
{rec.name}
@@ -338,7 +403,16 @@ export default function HistoryPage() {
Copy Link
</button>
{/* Download / Open */}
{/* Download */}
<button
onClick={() => downloadFile(rec.cid, rec.name).catch(() => {})}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
<Download className="w-3 h-3" />
Download
</button>
{/* Open */}
<a
href={gwLink}
target="_blank"
@@ -370,7 +444,7 @@ export default function HistoryPage() {
)}
{/* ── No search results ── */}
{history.length > 0 && filtered.length === 0 && (
{!loading && history.length > 0 && filtered.length === 0 && (
<div className="glass rounded-xl p-12 text-center">
<div className="flex justify-center mb-3">
<Search className="w-8 h-8 text-surface-500" />
@@ -387,6 +461,20 @@ export default function HistoryPage() {
</button>
</div>
)}
{/* ── Batch bar ── */}
<BatchBar
count={selected.size}
onClear={() => setSelected(new Set())}
actions={[
{
key: 'delete',
label: 'Delete selected',
icon: <Trash2 className="w-3.5 h-3.5" />,
variant: 'danger',
onClick: handleBatchDelete,
},
]}
/>
</div>
</PortalLayout>
);
+5 -4
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { listIPNSKeys, ipnsPublish, ipnsResolve, ipnsGenKey, type IPNSKey } from '@/lib/api';
import PortalLayout from '@/app/layout-portal';
import { RefreshCw, ExternalLink, Key, FileText, Plus } from 'lucide-react';
type Status = 'idle' | 'loading' | 'success' | 'error';
@@ -93,11 +94,11 @@ export default function IPNSPage() {
};
return (
<div className="space-y-6 max-w-4xl">
<PortalLayout>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold text-white">IPNS</h1>
<h1 className="text-2xl font-bold text-white">IPNS</h1>
<p className="text-sm text-surface-400 mt-0.5">
InterPlanetary Name System human-readable names for IPFS content
</p>
@@ -280,6 +281,6 @@ export default function IPNSPage() {
)}
</section>
</div>
</div>
</PortalLayout>
);
}
+10 -3
View File
@@ -1,21 +1,28 @@
import type { Metadata, Viewport } from 'next';
import './globals.css';
import Providers from '@/components/Providers';
import SWRegister from '@/components/SWRegister';
export const metadata: Metadata = {
title: 'MAOS IPFS Portal',
description: 'Decentralized storage management for your IPFS node',
manifest: '/manifest.json',
icons: { icon: '/favicon.svg' },
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
themeColor: '#020617',
themeColor: '#0891b2',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body className="min-h-screen antialiased">{children}</body>
<html lang="en" className="dark" suppressHydrationWarning>
<body className="min-h-screen antialiased">
<SWRegister />
<Providers>{children}</Providers>
</body>
</html>
);
}
+143
View File
@@ -0,0 +1,143 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuth } from '@/lib/auth';
import { useNotify } from '@/lib/notifications';
import { useRouter } from 'next/navigation';
import { Wallet, Loader2, Shield, ExternalLink } from 'lucide-react';
import { discoverWallets, connectWallet, type WalletProvider, type DetectedWallet } from '@/lib/wallet';
export default function LoginPage() {
const { loginWithWallet } = useAuth();
const { notify } = useNotify();
const router = useRouter();
const [wallets, setWallets] = useState<DetectedWallet[]>([]);
const [busy, setBusy] = useState(false);
const [busyWallet, setBusyWallet] = useState<string | null>(null);
/* Discover available wallets on mount */
useEffect(() => {
discoverWallets().then(setWallets);
}, []);
async function handleConnect(dw: DetectedWallet) {
setBusy(true);
setBusyWallet(dw.name);
try {
const { address } = await connectWallet(dw.provider);
await loginWithWallet(address, dw.provider);
notify({ type: 'success', title: 'Ingelogd', message: `Wallet ${address.slice(0, 6)}${address.slice(-4)}` });
router.push('/dashboard');
} catch (err: any) {
notify({ type: 'error', title: 'Login mislukt', message: err.message || 'Onbekende fout' });
} finally {
setBusy(false);
setBusyWallet(null);
}
}
/* Wallet icon — use EIP-6963 provider icon or fallback */
function walletIcon(dw: DetectedWallet): string | null {
return dw.icon ?? null;
}
return (
<div className="min-h-screen flex items-center justify-center bg-[#0a0e17] p-4">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="flex flex-col items-center mb-8">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-500 to-accent-purple flex items-center justify-center text-lg font-bold text-white mb-3">
M
</div>
<h1 className="text-xl font-bold text-white">IPFS Portal</h1>
<p className="text-sm text-surface-400 mt-1">Verbind je wallet om in te loggen</p>
</div>
{/* Wallet connect card */}
<div className="glass rounded-xl border border-surface-700 overflow-hidden">
{wallets.length === 0 && !busy && (
<div className="p-6 text-center">
<Wallet className="w-8 h-8 text-surface-500 mx-auto mb-3" />
<p className="text-sm text-surface-400 mb-3">Geen wallet extensie gevonden</p>
<p className="text-xs text-surface-500">
Installeer{' '}
<a href="https://metamask.io" target="_blank" rel="noopener noreferrer"
className="text-brand-400 hover:text-brand-300 transition-colors">
MetaMask
</a>
{' '}of{' '}
<a href="https://rabby.io" target="_blank" rel="noopener noreferrer"
className="text-brand-400 hover:text-brand-300 transition-colors">
Rabby
</a>
</p>
</div>
)}
{wallets.length > 0 && (
<div className="divide-y divide-surface-700/50">
{wallets.map((dw, i) => {
const isLoading = busy && busyWallet === dw.name;
const icon = walletIcon(dw);
return (
<button
key={i}
onClick={() => handleConnect(dw)}
disabled={busy}
className="w-full flex items-center gap-3 px-5 py-4 text-left hover:bg-surface-800/50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{/* Icon */}
<div className="w-10 h-10 rounded-xl bg-surface-800 flex items-center justify-center overflow-hidden shrink-0">
{icon ? (
<img src={icon} alt="" className="w-6 h-6" />
) : (
<Wallet className="w-5 h-5 text-surface-400" />
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-surface-200">
{dw.name}
{dw.isMAOS && (
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-accent-purple/20 text-accent-purple">
MAOS
</span>
)}
</div>
<div className="text-xs text-surface-500 mt-0.5">
Klik om te verbinden
</div>
</div>
{/* Action */}
{isLoading ? (
<Loader2 className="w-5 h-5 animate-spin text-surface-400 shrink-0" />
) : (
<ExternalLink className="w-4 h-4 text-surface-500 shrink-0" />
)}
</button>
);
})}
</div>
)}
{/* Refresh button */}
<button
onClick={() => discoverWallets().then(setWallets)}
className="w-full py-2.5 text-xs text-surface-500 hover:text-surface-300 border-t border-surface-700/50 transition-colors"
>
{wallets.length === 0 ? 'Zoek opnieuw naar wallets' : 'Vernieuw lijst'}
</button>
</div>
{/* Footer */}
<p className="text-center text-[10px] text-surface-600 mt-6">
<Shield className="w-3 h-3 inline-block mr-1" />
Verbonden met MAOS IPFS node
</p>
</div>
</div>
);
}
+8
View File
@@ -91,6 +91,14 @@ export default function LandingPage() {
)}
</button>
{/* Login link */}
<p className="mt-4 text-sm text-surface-500">
Of{' '}
<a href="/login" className="text-brand-400 hover:text-brand-300 transition-colors underline underline-offset-2">
log in met gebruikersnaam en wachtwoord
</a>
</p>
{/* Error */}
{error && (
<div className="mt-6 px-4 py-3 rounded-lg bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-sm">
+3 -2
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { getPeers } from '@/lib/api';
import PortalLayout from '@/app/layout-portal';
import { SkeletonTable } from '@/components/Skeleton';
import { Wifi, Globe, Clock } from 'lucide-react';
export default function PeersPage() {
@@ -31,13 +32,13 @@ export default function PeersPage() {
<div className="glass rounded-xl overflow-hidden">
{loading ? (
<div className="p-8 text-center text-sm text-surface-500">Loading</div>
<SkeletonTable rows={8} cols={3} />
) : peers.length === 0 ? (
<div className="p-8 text-center text-sm text-surface-500">No peers connected</div>
) : (
<div className="divide-y divide-surface-800">
{peers.map((p) => (
<div key={p.id} className="flex items-center justify-between px-5 py-3 hover:bg-surface-800/30 transition-colors">
<div key={`${p.id}-${p.addr}`} className="flex items-center justify-between px-5 py-3 hover:bg-surface-800/30 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="p-1.5 rounded-lg bg-accent-green/10">
<Wifi className="w-3.5 h-3.5 text-accent-green" />
+65 -2
View File
@@ -4,7 +4,10 @@ import { useEffect, useState } from 'react';
import { listPins, addPin, removePin } from '@/lib/api';
import PortalLayout from '@/app/layout-portal';
import Link from 'next/link';
import { HardDrive, Trash2, Plus, Search, Copy, CheckCircle, ExternalLink } from 'lucide-react';
import { HardDrive, Trash2, Plus, Search, Copy, CheckCircle, ExternalLink, Download } from 'lucide-react';
import Skeleton, { SkeletonRow, SkeletonTable } from '@/components/Skeleton';
import BatchBar, { type BatchAction } from '@/components/BatchBar';
import { useNotify } from '@/lib/notifications';
export default function PinsPage() {
const [pins, setPins] = useState<{ cid: string; name: string; size: number; created: string }[]>([]);
@@ -14,6 +17,9 @@ export default function PinsPage() {
const [newName, setNewName] = useState('');
const [showAdd, setShowAdd] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const { notify } = useNotify();
async function load() {
try {
@@ -51,6 +57,41 @@ export default function PinsPage() {
} catch { /* ignore */ }
}
function toggleSelect(cid: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(cid)) next.delete(cid);
else next.add(cid);
return next;
});
}
function clearSelection() {
setSelected(new Set());
}
async function handleBatchUnpin() {
const ids = Array.from(selected);
let success = 0;
let fail = 0;
for (const cid of ids) {
try {
await removePin(cid);
success++;
} catch {
fail++;
}
}
if (success > 0) {
notify({ type: 'success', title: `Unpinned ${success} pin${success > 1 ? 's' : ''}` });
}
if (fail > 0) {
notify({ type: 'error', title: `Failed to unpin ${fail} pin${fail > 1 ? 's' : ''}` });
}
clearSelection();
await load();
}
const filtered = pins.filter(
(p) => p.cid.includes(search) || (p.name && p.name.toLowerCase().includes(search.toLowerCase())),
);
@@ -112,7 +153,7 @@ export default function PinsPage() {
{/* Pin list */}
<div className="glass rounded-xl overflow-hidden">
{loading ? (
<div className="p-8 text-center text-sm text-surface-500">Loading</div>
<SkeletonTable rows={8} cols={3} />
) : filtered.length === 0 ? (
<div className="p-8 text-center text-sm text-surface-500">
{search ? 'No pins match your search' : 'No pins yet. Add one above.'}
@@ -121,6 +162,12 @@ export default function PinsPage() {
<div className="divide-y divide-surface-800">
{filtered.map((pin) => (
<div key={pin.cid} className="flex items-center justify-between px-5 py-3 hover:bg-surface-800/30 transition-colors">
<input
type="checkbox"
checked={selected.has(pin.cid)}
onChange={() => toggleSelect(pin.cid)}
className="w-4 h-4 rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500 shrink-0 mr-3"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<HardDrive className="w-3.5 h-3.5 text-surface-500 shrink-0" />
@@ -162,6 +209,22 @@ export default function PinsPage() {
</div>
)}
</div>
{/* Batch actions bar */}
<BatchBar
count={selected.size}
actions={[
{
key: 'unpin',
label: 'Unpin selected',
icon: <Trash2 className="w-3.5 h-3.5" />,
variant: 'danger',
onClick: handleBatchUnpin,
},
]}
onClear={clearSelection}
label="selected"
/>
</PortalLayout>
);
}
+185
View File
@@ -0,0 +1,185 @@
'use client';
import { useState } from 'react';
import PortalLayout from '@/app/layout-portal';
import { useAuth } from '@/lib/auth';
import { useNotify } from '@/lib/notifications';
import { useRouter } from 'next/navigation';
import {
User, Lock, LogOut, Save, Loader2, AlertTriangle, CheckCircle, Eye, EyeOff,
} from 'lucide-react';
export default function ProfilePage() {
const { user, logout, isAdmin } = useAuth();
const { notify } = useNotify();
const router = useRouter();
const [currentPass, setCurrentPass] = useState('');
const [newPass, setNewPass] = useState('');
const [confirmPass, setConfirmPass] = useState('');
const [showPasswords, setShowPasswords] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setError('');
if (!currentPass || !newPass || !confirmPass) {
setError('Vul alle wachtwoordvelden in');
return;
}
if (newPass.length < 6) {
setError('Nieuw wachtwoord moet minimaal 6 tekens zijn');
return;
}
if (newPass !== confirmPass) {
setError('Nieuwe wachtwoorden komen niet overeen');
return;
}
setBusy(true);
try {
const res = await fetch('/api/auth/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: user?.address, currentPassword: currentPass, newPassword: newPass }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Wachtwoord wijzigen mislukt');
}
notify({ type: 'success', title: 'Wachtwoord gewijzigd' });
setCurrentPass('');
setNewPass('');
setConfirmPass('');
} catch (err: any) {
setError(err.message);
} finally {
setBusy(false);
}
}
async function handleLogout() {
await logout();
notify({ type: 'info', title: 'Uitgelogd' });
router.push('/login');
}
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>
</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 ── */}
<div className="lg:col-span-1">
<div className="glass rounded-xl p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-500 to-accent-purple flex items-center justify-center text-lg font-bold text-white">
{user?.address?.charAt(2).toUpperCase() || '?'}
</div>
<div>
<div className="text-sm font-semibold text-white font-mono">{user?.address ? `${user.address.slice(0, 6)}...${user.address.slice(-4)}` : 'Onbekend'}</div>
<div className="text-xs text-surface-500">
{isAdmin ? 'Admin' : 'Gebruiker'}
</div>
</div>
</div>
<div className="pt-3 border-t border-surface-800 space-y-2">
<div className="flex justify-between text-xs">
<span className="text-surface-500">Rol</span>
<span className={`font-medium ${isAdmin ? 'text-accent-amber' : 'text-surface-300'}`}>
{isAdmin ? 'Administrator' : 'User'}
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-surface-500">Sessie</span>
<span className="text-accent-green font-medium">Actief</span>
</div>
</div>
</div>
</div>
{/* ── Wachtwoord wijzigen ── */}
<div className="lg:col-span-2">
<div className="glass rounded-xl p-5">
<h2 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
<Lock className="w-4 h-4 text-brand-400" />
Wachtwoord wijzigen
</h2>
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md">
{error && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-accent-rose/10 text-accent-rose text-xs">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
{error}
</div>
)}
<div>
<label className="text-xs text-surface-400 mb-1.5 block">Huidig wachtwoord</label>
<input
value={currentPass} onChange={(e) => setCurrentPass(e.target.value)}
type={showPasswords ? 'text' : 'password'}
autoComplete="current-password"
className="w-full rounded-lg bg-surface-900 border border-surface-700 px-3 py-2 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
<div className="relative">
<label className="text-xs text-surface-400 mb-1.5 block">Nieuw wachtwoord</label>
<input
value={newPass} onChange={(e) => setNewPass(e.target.value)}
type={showPasswords ? 'text' : 'password'}
autoComplete="new-password"
placeholder="Minimaal 6 tekens"
className="w-full rounded-lg bg-surface-900 border border-surface-700 px-3 py-2 pr-10 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
<div>
<label className="text-xs text-surface-400 mb-1.5 block">Bevestig nieuw wachtwoord</label>
<input
value={confirmPass} onChange={(e) => setConfirmPass(e.target.value)}
type={showPasswords ? 'text' : 'password'}
autoComplete="new-password"
className="w-full rounded-lg bg-surface-900 border border-surface-700 px-3 py-2 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
{/* Toggle show passwords */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showPasswords}
onChange={(e) => setShowPasswords(e.target.checked)}
className="rounded bg-surface-800 border-surface-600 text-brand-500 focus:ring-brand-500"
/>
<span className="text-xs text-surface-500">Toon wachtwoorden</span>
</label>
<button type="submit" disabled={busy}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{busy ? 'Bezig…' : 'Wachtwoord wijzigen'}
</button>
</form>
</div>
</div>
</div>
</PortalLayout>
);
}
+139
View File
@@ -0,0 +1,139 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { useNotify } from '@/lib/notifications';
import { UserPlus, Loader2, AlertTriangle, CheckCircle, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
export default function RegisterPage() {
const router = useRouter();
const { notify } = useNotify();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPass, setConfirmPass] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
if (!username.trim() || !password) {
setError('Vul alle velden in');
return;
}
if (password.length < 6) {
setError('Wachtwoord moet minimaal 6 tekens zijn');
return;
}
if (password !== confirmPass) {
setError('Wachtwoorden komen niet overeen');
return;
}
setBusy(true);
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim(), password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Registratie mislukt (${res.status})`);
}
notify({
type: 'success',
title: 'Account aangemaakt',
message: `Welkom, ${username.trim()}! Je kunt nu inloggen.`,
});
router.push('/login');
} catch (err: any) {
setError(err.message || 'Registratie mislukt');
} finally {
setBusy(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-[#0a0e17] p-4">
<div className="w-full max-w-sm">
{/* Header */}
<div className="flex flex-col items-center mb-8">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-500 to-accent-purple flex items-center justify-center text-lg font-bold text-white mb-3">
M
</div>
<h1 className="text-xl font-bold text-white">Account aanmaken</h1>
<p className="text-sm text-surface-400 mt-1">Maak een nieuw IPFS Portal account</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="glass rounded-xl p-6 border border-surface-700 space-y-4">
{error && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-accent-rose/10 text-accent-rose text-xs">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
{error}
</div>
)}
<div>
<label className="text-xs text-surface-400 mb-1.5 block">Gebruikersnaam</label>
<input
value={username} onChange={(e) => setUsername(e.target.value)}
placeholder="Kies een gebruikersnaam"
autoFocus
autoComplete="username"
className="w-full rounded-lg bg-surface-900 border border-surface-700 px-3 py-2.5 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-colors"
/>
</div>
<div>
<label className="text-xs text-surface-400 mb-1.5 block">Wachtwoord</label>
<input
value={password} onChange={(e) => setPassword(e.target.value)}
type="password" placeholder="Minimaal 6 tekens"
autoComplete="new-password"
className="w-full rounded-lg bg-surface-900 border border-surface-700 px-3 py-2.5 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-colors"
/>
</div>
<div>
<label className="text-xs text-surface-400 mb-1.5 block">Bevestig wachtwoord</label>
<input
value={confirmPass} onChange={(e) => setConfirmPass(e.target.value)}
type="password" placeholder="Nogmaals je wachtwoord"
autoComplete="new-password"
className="w-full rounded-lg bg-surface-900 border border-surface-700 px-3 py-2.5 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-colors"
/>
</div>
<button type="submit" disabled={busy}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
{busy ? 'Bezig…' : 'Account aanmaken'}
</button>
<p className="text-center text-xs text-surface-500">
Heb je al een account?{' '}
<Link href="/login" className="text-brand-400 hover:text-brand-300 transition-colors">
Inloggen
</Link>
</p>
</form>
{/* Back */}
<div className="text-center mt-6">
<Link href="/" className="inline-flex items-center gap-1 text-xs text-surface-500 hover:text-surface-300 transition-colors">
<ArrowLeft className="w-3 h-3" />
Terug naar home
</Link>
</div>
</div>
</div>
);
}
+9 -7
View File
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { getSettings, saveSettings, resetSettings, type PortalSettings } from '@/lib/storage';
import { useTheme } from '@/lib/theme';
import PortalLayout from '@/app/layout-portal';
import {
Settings, Globe, Server, HardDrive, RefreshCw,
@@ -18,6 +19,7 @@ function formatStorageMB(mb: number): string {
/* ════════════════════════════ Page ════════════════════════════ */
export default function SettingsPage() {
const { setTheme: applyTheme } = useTheme();
const [form, setForm] = useState<PortalSettings>(() => getSettings());
const [original, setOriginal] = useState<PortalSettings>(() => getSettings());
const [saved, setSaved] = useState(false);
@@ -228,23 +230,23 @@ export default function SettingsPage() {
<div>
<label className="text-xs text-surface-400 mb-2 block">Appearance</label>
<div className="flex gap-2">
{(['dark', 'light'] as const).map(theme => (
{(['dark', 'light'] as const).map(t => (
<button
key={theme}
onClick={() => update('theme', theme)}
key={t}
onClick={() => { update('theme', t); applyTheme(t); }}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${
form.theme === theme
form.theme === t
? 'bg-brand-500/20 text-brand-400 border border-brand-500/30'
: 'bg-surface-900 text-surface-400 border border-surface-700 hover:border-surface-500'
}`}
>
{theme === 'dark' ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
{theme.charAt(0).toUpperCase() + theme.slice(1)}
{t === 'dark' ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
<p className="mt-1.5 text-[11px] text-surface-500">
Theme preference is saved but only affects new sessions. Full theme switching coming soon.
Theme wordt direct toegepast en opgeslagen in je browser.
</p>
</div>
),
+142
View File
@@ -0,0 +1,142 @@
'use client';
import { type RefObject } from 'react';
import {
File, CheckCircle, Copy, ExternalLink, Trash2, ArrowUp,
} from 'lucide-react';
import PaymentPanel from '@/components/PaymentPanel';
/* ── 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];
}
/* ── Props ── */
interface CryptoUploadProps {
cryptoFile: File | null;
cryptoResult: { cid: string; size: number } | null;
copied: string | null;
cryptoInputRef: RefObject<HTMLInputElement | null>;
onCryptoSelect: () => void;
onCryptoFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onCryptoClear: () => void;
onCryptoPaid: (info: { cid: string; txHash?: string; method: string }) => void;
doCryptoUpload: () => Promise<{ cid: string; size: number }>;
onCopyCid: (cid: string) => void;
gatewayLink: (cid: string) => string;
}
/* ════════════════════════════ Component ════════════════════════════ */
export default function CryptoUpload({
cryptoFile, cryptoResult, copied, cryptoInputRef,
onCryptoSelect, onCryptoFileChange, onCryptoClear,
onCryptoPaid, doCryptoUpload, onCopyCid, gatewayLink,
}: CryptoUploadProps) {
return (
<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={() => onCopyCid(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>
);
}
+316
View File
@@ -0,0 +1,316 @@
'use client';
import { type DragEvent, type RefObject } from 'react';
import {
Upload, File, CheckCircle, XCircle, Loader2,
Copy, ExternalLink, Trash2, Globe,
} from 'lucide-react';
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];
}
export interface FileEntry {
id: string;
file: File;
preview?: string;
status: 'pending' | 'uploading' | 'done' | 'error';
result?: { cid: string; size: number };
errorMsg?: string;
date?: string;
}
interface QuickUploadProps {
files: FileEntry[];
dragOver: boolean;
uploading: boolean;
uploadIndex: number;
uploadDone: boolean;
copied: string | null;
inputRef: RefObject<HTMLInputElement | null>;
onAddFiles: (files: FileList | File[]) => void;
onRemoveFile: (id: string) => void;
onDragOver: (e: DragEvent) => void;
onDragLeave: () => void;
onDrop: (e: DragEvent) => void;
onBrowse: () => void;
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onStartUpload: () => void;
onCopyCid: (cid: string) => void;
onCopyShareLink: (cid: string) => void;
onClearAll: () => void;
gatewayLink: (cid: string) => string;
}
export default function QuickUpload({
files, dragOver, uploading, uploadIndex, uploadDone, copied,
inputRef, onAddFiles, onRemoveFile, onDragOver, onDragLeave, onDrop,
onBrowse, onInputChange, onStartUpload, onCopyCid, onCopyShareLink, onClearAll, gatewayLink,
}: QuickUploadProps) {
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');
return (
<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={onClearAll}
className="text-xs text-surface-500 hover:text-accent-rose transition-colors"
>
Clear all
</button>
)}
</div>
{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>
)}
<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` }}
>
<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>
<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>
<div className="flex items-center gap-1 shrink-0">
{entry.status === 'done' && entry.result && (
<>
<button
onClick={() => onCopyCid(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={() => onRemoveFile(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={onStartUpload}
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={onClearAll}
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={() => onCopyCid(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={() => onCopyShareLink(entry.result!.cid)}
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>
);
}
+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>
);
+171
View File
@@ -0,0 +1,171 @@
'use client';
import { useEffect, useState } from 'react';
import PortalLayout from '@/app/layout-portal';
import { getRepoStats, getBWStats, getPeers, listPins } from '@/lib/api';
import type { RepoStats, BWStats } from '@/lib/api';
import { checkUploadAllowed, type UsageCheckResult } from '@/lib/limits';
import { useAuth } from '@/lib/auth';
import StorageGauge from '@/app/dashboard/components/StorageGauge';
import FreeTierProgress from '@/app/dashboard/components/FreeTierProgress';
import {
HardDrive, Upload, Wifi, Database, Pin, Activity,
ArrowUpRight, ArrowDownLeft,
} from 'lucide-react';
export default function UsagePage() {
const { user } = useAuth();
const [usage, setUsage] = useState<UsageCheckResult | null>(null);
const [repo, setRepo] = useState<RepoStats | null>(null);
const [bw, setBW] = useState<BWStats | null>(null);
const [peerCount, setPeerCount] = useState(0);
const [pinCount, setPinCount] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
try {
const [u, r, b, p, pins] = await Promise.all([
checkUploadAllowed().catch(() => null),
getRepoStats().catch(() => null),
getBWStats().catch(() => null),
getPeers().catch(() => []),
listPins().catch(() => []),
]);
setUsage(u);
setRepo(r);
setBW(b);
setPeerCount(p.length);
setPinCount(pins.length);
} finally {
setLoading(false);
}
}
load();
}, []);
const usedGB = repo ? repo.repoSize / 1e9 : 0;
const maxGB = repo ? repo.storageMax / 1e9 : 30;
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>
{loading ? (
<div className="flex items-center justify-center h-40">
<div className="w-5 h-5 rounded-full border-2 border-brand-500 border-t-transparent animate-spin" />
</div>
) : (
<div className="space-y-6">
{/* ── Row 1: Storage + Free Tier ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<StorageGauge usedGB={usedGB} maxGB={maxGB} label="IPFS Opslag" />
<FreeTierProgress
used={usage?.current.todayUploadCount ?? 0}
max={usage?.quota.freeUploadsPerDay ?? 50}
label="Gratis uploads vandaag"
/>
</div>
{/* ── Row 2: KPI cards ── */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="glass rounded-xl p-4">
<div className="flex items-center gap-1.5 text-[10px] text-surface-500 mb-1">
<Upload className="w-3 h-3" /> Totale Uploads
</div>
<div className="text-xl font-bold text-white">
{usage?.current.uploadCount ?? 0}
</div>
</div>
<div className="glass rounded-xl p-4">
<div className="flex items-center gap-1.5 text-[10px] text-surface-500 mb-1">
<Pin className="w-3 h-3" /> Pins
</div>
<div className="text-xl font-bold text-white">{pinCount}</div>
<div className="text-[10px] text-surface-500 mt-0.5">
/ {usage?.quota.maxPins.toLocaleString() ?? '—'}
</div>
</div>
<div className="glass rounded-xl p-4">
<div className="flex items-center gap-1.5 text-[10px] text-surface-500 mb-1">
<Wifi className="w-3 h-3" /> Peers
</div>
<div className="text-xl font-bold text-white">{peerCount}</div>
</div>
<div className="glass rounded-xl p-4">
<div className="flex items-center gap-1.5 text-[10px] text-surface-500 mb-1">
<HardDrive className="w-3 h-3" /> Gebruikt
</div>
<div className="text-xl font-bold text-white">{usedGB.toFixed(1)}</div>
<div className="text-[10px] text-surface-500 mt-0.5">GB / {maxGB.toFixed(0)} GB</div>
</div>
</div>
{/* ── Row 3: Bandwidth + Repo ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Bandwidth */}
<div className="glass rounded-xl p-5">
<h3 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
<Activity className="w-4 h-4 text-accent-amber" />
Bandbreedte
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-surface-900/50 border border-surface-800">
<div className="flex items-center gap-2">
<ArrowDownLeft className="w-4 h-4 text-accent-green" />
<span className="text-xs text-surface-400">In</span>
</div>
<span className="text-sm font-semibold text-surface-200">
{bw ? (bw.totalIn / 1e6).toFixed(1) : '—'} MB
</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-surface-900/50 border border-surface-800">
<div className="flex items-center gap-2">
<ArrowUpRight className="w-4 h-4 text-accent-rose" />
<span className="text-xs text-surface-400">Uit</span>
</div>
<span className="text-sm font-semibold text-surface-200">
{bw ? (bw.totalOut / 1e6).toFixed(1) : '—'} MB
</span>
</div>
</div>
<p className="text-[10px] text-surface-500 mt-3 text-center">Sinds node start</p>
</div>
{/* Quota details */}
<div className="glass rounded-xl p-5">
<h3 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
<Database className="w-4 h-4 text-accent-purple" />
Quota
</h3>
<div className="space-y-2.5">
{([
['Max opslag', `${(usage?.quota.maxStorageMB ?? 30720) / 1024} GB`],
['Max uploads', (usage?.quota.maxUploads ?? 1000).toLocaleString()],
['Gratis uploads/dag', String(usage?.quota.freeUploadsPerDay ?? 50)],
['Max pins', (usage?.quota.maxPins ?? 10000).toLocaleString()],
] as const).map(([label, value]) => (
<div key={label} className="flex justify-between py-1.5 border-b border-surface-800/50 last:border-0">
<span className="text-xs text-surface-400">{label}</span>
<span className="text-xs text-surface-200 font-medium">{value}</span>
</div>
))}
</div>
{!usage?.allowed && usage?.reason && (
<div className="mt-4 p-3 rounded-lg bg-accent-rose/10 border border-accent-rose/20">
<p className="text-xs text-accent-rose"> {usage.reason}</p>
</div>
)}
</div>
</div>
</div>
)}
</PortalLayout>
);
}
+2 -1
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { listUsers, createUser, deleteUser } from '@/lib/api';
import PortalLayout from '@/app/layout-portal';
import { SkeletonTable } from '@/components/Skeleton';
import { paymentService, type UserFullStats, type UploadRecord } from '@/lib/payment';
import { formatWeiToETH, formatBytes } from '@/lib/wallet';
import {
@@ -132,7 +133,7 @@ export default function UsersPage() {
<div className="lg:col-span-3">
<div className="glass rounded-xl overflow-hidden">
{loading ? (
<div className="p-8 text-center text-sm text-surface-500">Loading</div>
<SkeletonTable rows={8} cols={3} />
) : users.length === 0 ? (
<div className="p-8 text-center text-sm text-surface-500">No users configured</div>
) : (