forked from maik/IPFS-portal
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:
Generated
+922
-1
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -9,13 +9,16 @@
|
||||
"export": "next build && npx serve out"
|
||||
},
|
||||
"dependencies": {
|
||||
"@maos/ipfs-portal": "file:",
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "^6.2.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "^16.2.7",
|
||||
"postcss": "^8.5.15",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"swr": "^2.4.2",
|
||||
"viem": "^2.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -24,6 +27,7 @@
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.8.0"
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#a855f7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="6" fill="url(#g)"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="system-ui" font-weight="800" font-size="18" fill="white">M</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 446 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
||||
<rect width="192" height="192" rx="32" fill="#0891b2"/>
|
||||
<text x="96" y="108" font-family="system-ui, sans-serif" font-size="96" font-weight="bold" fill="white" text-anchor="middle">M</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 289 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="64" fill="#0891b2"/>
|
||||
<text x="256" y="290" font-family="system-ui, sans-serif" font-size="256" font-weight="bold" fill="white" text-anchor="middle">M</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 291 B |
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "MAOS IPFS Portal",
|
||||
"short_name": "IPFS Portal",
|
||||
"description": "Decentralized storage management for your IPFS node",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0e17",
|
||||
"theme_color": "#0891b2",
|
||||
"orientation": "any",
|
||||
"categories": ["utilities", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/* ── IPFS Portal — Service Worker v2 ──
|
||||
*
|
||||
* Cache strategieën per route type:
|
||||
* - Precache: app shell (alle pages)
|
||||
* - Cache-first: static assets (fonts, images, CSS, JS)
|
||||
* - Network-first: API calls (/_next/data, /api/*)
|
||||
* - Stale-while-revalidate: navigatie requests
|
||||
*/
|
||||
|
||||
const CACHE = 'ipfs-portal-v2';
|
||||
const STATIC_CACHE = 'ipfs-portal-static-v2';
|
||||
const DATA_CACHE = 'ipfs-portal-data-v2';
|
||||
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/dashboard',
|
||||
'/upload',
|
||||
'/explorer',
|
||||
'/pins',
|
||||
'/history',
|
||||
'/usage',
|
||||
'/peers',
|
||||
'/users',
|
||||
'/ipns',
|
||||
'/login',
|
||||
'/settings',
|
||||
'/profile',
|
||||
];
|
||||
|
||||
/* ── Install: precache app shell ── */
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE).then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
/* ── Activate: clean old caches ── */
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((k) => k !== CACHE && k !== STATIC_CACHE && k !== DATA_CACHE)
|
||||
.map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
/* ── Helper: is this a navigation request? ── */
|
||||
|
||||
function isNavigation(req) {
|
||||
return req.mode === 'navigate';
|
||||
}
|
||||
|
||||
function isStaticAsset(url) {
|
||||
return /\.(png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|eot|css|js|json)$/i.test(url.pathname);
|
||||
}
|
||||
|
||||
function isAPIRequest(url) {
|
||||
return url.pathname.startsWith('/api/') || url.pathname.startsWith('/_next/data/');
|
||||
}
|
||||
|
||||
/* ── Fetch handler ── */
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Same-origin only
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// 1. Static assets → cache-first
|
||||
if (isStaticAsset(url)) {
|
||||
event.respondWith(cacheFirst(request, STATIC_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. API / Next.js data → network-first
|
||||
if (isAPIRequest(url)) {
|
||||
event.respondWith(networkFirst(request, DATA_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Navigation → stale-while-revalidate
|
||||
if (isNavigation(request)) {
|
||||
event.respondWith(staleWhileRevalidate(request, CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. All other requests → network-first with cache fallback
|
||||
event.respondWith(networkFirst(request, CACHE));
|
||||
});
|
||||
|
||||
/* ── Cache strategies ── */
|
||||
|
||||
async function cacheFirst(request, cacheName) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
return new Response('Offline', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
async function networkFirst(request, cacheName) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
return new Response('Offline', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
const fetchPromise = fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) cache.put(request, response.clone());
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
|
||||
return cached || fetchPromise;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* ── Generate PNG icons from SVG ── */
|
||||
const sharp = require('sharp');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function main() {
|
||||
const sizes = [192, 512];
|
||||
for (const size of sizes) {
|
||||
const svg = fs.readFileSync(path.join(__dirname, '..', 'public', 'icon-' + size + '.svg'));
|
||||
await sharp(svg)
|
||||
.resize(size, size)
|
||||
.png()
|
||||
.toFile(path.join(__dirname, 'icon-' + size + '.png'));
|
||||
console.log(`Generated icon-${size}.png`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
+63
-320
@@ -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"
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── 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"
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── 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"
|
||||
<TiersView
|
||||
tiers={tiers}
|
||||
isOwner={isOwner}
|
||||
tierBalance={tierBalance}
|
||||
setTierBalance={setTierBalance}
|
||||
tierBps={tierBps}
|
||||
setTierBps={setTierBps}
|
||||
doTx={doTx}
|
||||
provider={provider}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+52
-42
@@ -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>
|
||||
<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" />
|
||||
refreshing…
|
||||
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">
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>')
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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 && <> · 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>
|
||||
);
|
||||
}
|
||||
+76
-406
@@ -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);
|
||||
function copyCid(cid: string) {
|
||||
navigator.clipboard.writeText(cid).catch(() => {});
|
||||
setCopied(cid);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
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
|
||||
<QuickUpload
|
||||
files={files}
|
||||
dragOver={dragOver}
|
||||
uploading={uploading}
|
||||
uploadIndex={uploadIndex}
|
||||
uploadDone={uploadDone}
|
||||
copied={copied}
|
||||
inputRef={inputRef}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
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}
|
||||
onDrop={onDrop}
|
||||
onBrowse={onBrowse}
|
||||
onInputChange={onInputChange}
|
||||
onStartUpload={startUpload}
|
||||
onCopyCid={copyCid}
|
||||
onCopyShareLink={copyShareLink}
|
||||
onClearAll={clearAll}
|
||||
gatewayLink={gatewayLink}
|
||||
/>
|
||||
<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 & 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 && <> · 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>
|
||||
)}
|
||||
|
||||
{/* ════════════ 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}
|
||||
<CryptoUpload
|
||||
cryptoFile={cryptoFile}
|
||||
cryptoResult={cryptoResult}
|
||||
copied={copied}
|
||||
cryptoInputRef={cryptoInputRef}
|
||||
onCryptoSelect={onCryptoSelect}
|
||||
onCryptoFileChange={onCryptoFileChange}
|
||||
onCryptoClear={onCryptoClear}
|
||||
onCryptoPaid={onCryptoPaid}
|
||||
doCryptoUpload={doCryptoUpload}
|
||||
onCopyCid={copyCid}
|
||||
gatewayLink={gatewayLink}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</PortalLayout>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode;
|
||||
/** Require admin role (default: false) */
|
||||
requireAdmin?: boolean;
|
||||
/** Redirect target for unauthenticated users (default: /login) */
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export default function AuthGuard({
|
||||
children,
|
||||
requireAdmin = false,
|
||||
redirectTo = '/login',
|
||||
}: AuthGuardProps) {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
if (!user) {
|
||||
router.replace(redirectTo);
|
||||
} else if (requireAdmin && user.role !== 'admin') {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [user, loading, requireAdmin, redirectTo, router]);
|
||||
|
||||
/* ── Loading state ── */
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#0a0e17]">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-brand-500 border-t-transparent animate-spin" />
|
||||
<p className="text-sm text-surface-500">Bezig met laden…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Not authenticated ── */
|
||||
if (!user) {
|
||||
return null; // useEffect handles redirect
|
||||
}
|
||||
|
||||
/* ── Not admin → redirect handled by useEffect ── */
|
||||
if (requireAdmin && user.role !== 'admin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ BatchBar ════════════════════════════
|
||||
* Floating "N selected" action bar voor batch operaties.
|
||||
* Verschijnt onderaan zodra >= 1 item geselecteerd is.
|
||||
*/
|
||||
|
||||
import { Trash2, Download, X } from 'lucide-react';
|
||||
|
||||
export interface BatchAction {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
variant?: 'danger' | 'default';
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface BatchBarProps {
|
||||
count: number;
|
||||
actions: BatchAction[];
|
||||
onClear: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function BatchBar({ count, actions, onClear, label = 'geselecteerd' }: BatchBarProps) {
|
||||
if (count === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-slide-up">
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-surface-800 border border-surface-700 shadow-xl shadow-black/30">
|
||||
{/* Count badge */}
|
||||
<span className="text-sm text-surface-200 font-medium whitespace-nowrap">
|
||||
<span className="text-brand-400">{count}</span> {label}
|
||||
</span>
|
||||
|
||||
<div className="w-px h-5 bg-surface-700" />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
onClick={action.onClick}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
action.variant === 'danger'
|
||||
? 'bg-accent-rose/10 text-accent-rose hover:bg-accent-rose/20'
|
||||
: 'bg-brand-500/10 text-brand-400 hover:bg-brand-500/20'
|
||||
}`}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="p-1.5 rounded-lg text-surface-500 hover:text-surface-300 hover:bg-surface-700 transition-colors"
|
||||
aria-label="Deselecteer alles"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ ErrorBoundary ════════════════════════════
|
||||
* Vangt render crashes op en toont een fallback UI i.p.v. een lege pagina.
|
||||
*/
|
||||
|
||||
import { Component, type ReactNode, type ErrorInfo } from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
/** Optional: label om te tonen welk onderdeel crashed */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
info: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null, info: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error(`[ErrorBoundary${this.props.label ? ` ${this.props.label}` : ''}]`, error, info);
|
||||
this.setState({ info });
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ error: null, info: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-8 text-center max-w-lg mx-auto my-8">
|
||||
<AlertCircle className="w-10 h-10 text-accent-rose mx-auto mb-4" />
|
||||
<h3 className="text-surface-200 font-semibold mb-1">
|
||||
{this.props.label ? `Fout in ${this.props.label}` : 'Er ging iets mis'}
|
||||
</h3>
|
||||
<p className="text-xs text-surface-500 mb-2 max-w-sm mx-auto">
|
||||
{this.state.error.message || 'Onbekende fout'}
|
||||
</p>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="inline-flex items-center gap-2 px-5 py-2 rounded-xl bg-brand-600 hover:bg-brand-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Probeer opnieuw
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export default function PaymentPanel({
|
||||
if (contractAddress) {
|
||||
paymentService.setContractAddress(contractAddress);
|
||||
}
|
||||
paymentService.isDeployed().then(setDeployed);
|
||||
paymentService.isDeployed().then(setDeployed).catch(() => setDeployed(false));
|
||||
}, [contractAddress]);
|
||||
|
||||
// Refresh price + stats when address or fileSize changes
|
||||
@@ -98,11 +98,8 @@ export default function PaymentPanel({
|
||||
setProvider(prov);
|
||||
|
||||
// Detect wallet type
|
||||
// @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');
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { checkHealth } from '@/lib/api';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { Shield, LogOut } from 'lucide-react';
|
||||
|
||||
export default function PortalHeader() {
|
||||
const { user, logout } = useAuth();
|
||||
const [nodeStatus, setNodeStatus] = useState<'online' | 'offline' | 'checking'>('checking');
|
||||
const [nodeVersion, setNodeVersion] = useState('');
|
||||
|
||||
@@ -43,11 +45,22 @@ export default function PortalHeader() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Wallet badge placeholder */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg glass text-xs text-surface-300">
|
||||
{/* Wallet badge */}
|
||||
{user && (
|
||||
<div className="flex items-center gap-2 pl-3 pr-2 py-1.5 rounded-lg bg-surface-800/50 text-xs">
|
||||
<Shield className="w-3.5 h-3.5 text-accent-cyan" />
|
||||
Wallet Connected
|
||||
<span className="text-surface-300 font-mono">
|
||||
{user.address.slice(0, 6)}...{user.address.slice(-4)}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="ml-1 p-1 rounded-md hover:bg-surface-700 text-surface-500 hover:text-accent-rose transition-colors"
|
||||
title="Uitloggen"
|
||||
>
|
||||
<LogOut className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Upload,
|
||||
@@ -14,10 +15,14 @@ import {
|
||||
Shield,
|
||||
LogOut,
|
||||
Clock,
|
||||
BarChart3,
|
||||
Sun,
|
||||
Moon,
|
||||
} from 'lucide-react';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/usage', label: 'Usage', icon: BarChart3 },
|
||||
{ href: '/upload', label: 'Upload', icon: Upload },
|
||||
{ href: '/explorer', label: 'Explorer', icon: Globe },
|
||||
{ href: '/pins', label: 'Pins', icon: HardDrive },
|
||||
@@ -31,13 +36,14 @@ const NAV_ITEMS = [
|
||||
|
||||
export default function PortalSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { theme, toggle: toggleTheme } = useTheme();
|
||||
|
||||
function handleDisconnect() {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 bottom-0 w-56 flex flex-col glass border-r border-surface-800 z-50">
|
||||
<aside aria-label="Main navigation" className="fixed left-0 top-0 bottom-0 w-56 flex flex-col glass border-r border-surface-800 z-50">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2.5 px-5 h-14 border-b border-surface-800 shrink-0">
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-brand-500 to-accent-purple flex items-center justify-center text-xs font-bold text-white">
|
||||
@@ -67,8 +73,15 @@ export default function PortalSidebar() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Disconnect */}
|
||||
<div className="p-3 border-t border-surface-800">
|
||||
{/* Theme toggle + Disconnect */}
|
||||
<div className="p-3 border-t border-surface-800 space-y-1">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-surface-500 hover:text-surface-200 hover:bg-surface-800/50 transition-all duration-150"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4 shrink-0" /> : <Moon className="w-4 h-4 shrink-0" />}
|
||||
{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-surface-500 hover:text-accent-rose hover:bg-accent-rose/5 transition-all duration-150"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { NotificationProvider } from '@/lib/notifications';
|
||||
import { AuthProvider } from '@/lib/auth';
|
||||
import { ThemeProvider } from '@/lib/theme';
|
||||
import ToastContainer from './ToastContainer';
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<NotificationProvider>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</NotificationProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ SWRegister ════════════════════════════
|
||||
* Registreert de service worker voor PWA offline support.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function SWRegister() {
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// SW registratie mislukt — geen offline, app werkt gewoon
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ SearchBar ════════════════════════════
|
||||
*
|
||||
* Reusable search bar met debounce, resultaten dropdown, en toetsenbordnavigatie.
|
||||
* Gebruikt de client-side search index (search-index.ts) of een custom onSearch callback.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Search, X, FileIcon, FolderIcon, Loader2 } from 'lucide-react';
|
||||
import { searchIndex, type SearchResult } from '@/lib/search-index';
|
||||
|
||||
/* ════════════════════════════ Types ════════════════════════════ */
|
||||
|
||||
export interface SearchBarProps {
|
||||
/** External search handler (optioneel — gebruikt search-index.ts anders) */
|
||||
onSearch?: (query: string) => Promise<SearchResult[]> | SearchResult[];
|
||||
/** Called when user selects a result */
|
||||
onSelect: (result: SearchResult) => void;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Auto-focus on mount */
|
||||
autoFocus?: boolean;
|
||||
/** Extra CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Component ════════════════════════════ */
|
||||
|
||||
export default function SearchBar({
|
||||
onSearch,
|
||||
onSelect,
|
||||
placeholder = 'Search files, CIDs, and content…',
|
||||
autoFocus = false,
|
||||
className = '',
|
||||
}: SearchBarProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [selectedIdx, setSelectedIdx] = useState(-1);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── Search logic ── */
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
if (!q || q.trim().length < 2) {
|
||||
setResults([]);
|
||||
setShowResults(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const hits = onSearch
|
||||
? await onSearch(q)
|
||||
: searchIndex(q);
|
||||
setResults(hits);
|
||||
setShowResults(true);
|
||||
setSelectedIdx(-1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onSearch]);
|
||||
|
||||
/* ── Debounced input ── */
|
||||
|
||||
function handleChange(value: string) {
|
||||
setQuery(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => doSearch(value), 250);
|
||||
}
|
||||
|
||||
/* ── Keyboard navigation ── */
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (!showResults || results.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIdx((prev) => Math.min(prev + 1, results.length - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIdx((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIdx >= 0 && selectedIdx < results.length) {
|
||||
handleSelect(results[selectedIdx]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setShowResults(false);
|
||||
inputRef.current?.blur();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Select ── */
|
||||
|
||||
function handleSelect(result: SearchResult) {
|
||||
setShowResults(false);
|
||||
setQuery('');
|
||||
onSelect(result);
|
||||
}
|
||||
|
||||
/* ── Click outside ── */
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
setShowResults(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
/* ── Cleanup ── */
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) inputRef.current?.focus();
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/* ════════════════════════════ Render ════════════════════════════ */
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className={`relative ${className}`}>
|
||||
{/* Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => { if (results.length > 0) setShowResults(true); }}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-10 pr-10 py-2.5 rounded-xl bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/50 focus-visible:border-brand-500 transition-colors"
|
||||
autoFocus={autoFocus}
|
||||
role="combobox"
|
||||
aria-expanded={showResults}
|
||||
aria-haspopup="listbox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="search-results-listbox"
|
||||
/>
|
||||
{loading && (
|
||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 animate-spin" />
|
||||
)}
|
||||
{!loading && query && (
|
||||
<button
|
||||
onClick={() => { setQuery(''); setResults([]); setShowResults(false); inputRef.current?.focus(); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-700 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-surface-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live region for screen readers */}
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{showResults ? `${results.length} resultaten` : ''}
|
||||
</div>
|
||||
|
||||
{/* Results dropdown */}
|
||||
{showResults && (
|
||||
<div
|
||||
id="search-results-listbox"
|
||||
role="listbox"
|
||||
className="absolute top-full left-0 right-0 mt-2 glass rounded-xl overflow-hidden shadow-xl z-50 max-h-72 overflow-y-auto animate-fade-in">
|
||||
{results.length === 0 && !loading ? (
|
||||
<div className="p-4 text-sm text-surface-500 text-center">No results found</div>
|
||||
) : (
|
||||
results.map((result, idx) => (
|
||||
<button
|
||||
key={result.entry.cid + result.matchField}
|
||||
id={`search-result-${idx}`}
|
||||
role="option"
|
||||
aria-selected={idx === selectedIdx}
|
||||
onClick={() => handleSelect(result)}
|
||||
onMouseEnter={() => setSelectedIdx(idx)}
|
||||
className={`w-full flex items-start gap-3 px-4 py-3 text-left text-sm transition-colors ${
|
||||
idx === selectedIdx ? 'bg-surface-700/50' : 'hover:bg-surface-800/50'
|
||||
}`}
|
||||
>
|
||||
{result.entry.type === 'dir'
|
||||
? <FolderIcon className="w-4 h-4 text-accent-amber shrink-0 mt-0.5" />
|
||||
: <FileIcon className="w-4 h-4 text-accent-blue shrink-0 mt-0.5" />
|
||||
}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-surface-200 font-medium truncate">
|
||||
{result.entry.name || result.entry.cid.slice(0, 20) + '…'}
|
||||
</span>
|
||||
{result.matchField === 'name' && (
|
||||
<span className="text-[10px] text-brand-400 bg-brand-500/10 px-1.5 py-0.5 rounded-full shrink-0">Name</span>
|
||||
)}
|
||||
{result.matchField === 'cid' && (
|
||||
<span className="text-[10px] text-accent-cyan bg-accent-cyan/10 px-1.5 py-0.5 rounded-full shrink-0">CID</span>
|
||||
)}
|
||||
</div>
|
||||
{result.entry.text && (
|
||||
<p className="text-xs text-surface-500 mt-0.5 line-clamp-1">
|
||||
{result.entry.text.slice(0, 120)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[11px] text-surface-600 font-mono mt-0.5">
|
||||
{result.entry.cid.slice(0, 16)}…
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[11px] text-surface-500 shrink-0">
|
||||
{result.entry.size > 0
|
||||
? result.entry.size < 1024
|
||||
? `${result.entry.size} B`
|
||||
: `${(result.entry.size / 1024).toFixed(1)} KB`
|
||||
: ''}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ Skeleton ════════════════════════════
|
||||
* Pulse-animatie placeholder voor loading states.
|
||||
* Gebruik: <Skeleton className="h-4 w-48" /> voor een tekstregel
|
||||
* <Skeleton className="h-24 w-full rounded-xl" /> voor een card
|
||||
*/
|
||||
|
||||
export default function Skeleton({ className = '', style }: { className?: string; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse rounded-md bg-surface-800 ${className}`}
|
||||
style={{ animationDuration: '1.5s', ...style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Rij skelet voor lijsten (pins, peers, users, history) ── */
|
||||
|
||||
export function SkeletonRow({ cols = 3 }: { cols?: number }) {
|
||||
const widths = ['w-48', 'w-32', 'w-20'];
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-5 py-3">
|
||||
<Skeleton className="w-4 h-4 rounded shrink-0" />
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className={`h-4 ${widths[i] ?? 'w-24'}`}
|
||||
/>
|
||||
))}
|
||||
<Skeleton className="h-4 w-16 ml-auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Card skelet voor dashboard ── */
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="glass rounded-xl p-5 space-y-3">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-7 w-32" />
|
||||
<Skeleton className="h-2 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Tabel skelet ── */
|
||||
|
||||
export function SkeletonTable({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<div className="divide-y divide-surface-800">
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div key={r} className="flex items-center gap-4 px-5 py-3">
|
||||
<Skeleton className="w-5 h-5 rounded shrink-0" />
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<Skeleton key={c} className="h-4 flex-1" style={{ maxWidth: `${60 + c * 15}px` }} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
|
||||
import type { Notification } from '@/lib/notifications';
|
||||
|
||||
/* ── Icons ── */
|
||||
|
||||
const ICONS: Record<Notification['type'], { icon: typeof CheckCircle; color: string }> = {
|
||||
success: { icon: CheckCircle, color: 'text-accent-green' },
|
||||
error: { icon: AlertCircle, color: 'text-accent-rose' },
|
||||
info: { icon: Info, color: 'text-accent-cyan' },
|
||||
warning: { icon: AlertTriangle,color: 'text-accent-amber' },
|
||||
};
|
||||
|
||||
/* ════════════════════════════ Toast ════════════════════════════ */
|
||||
|
||||
export default function Toast({
|
||||
notification,
|
||||
onDismiss,
|
||||
}: {
|
||||
notification: Notification;
|
||||
onDismiss: (id: string) => void;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [progress, setProgress] = useState(100);
|
||||
const { icon: Icon, color } = ICONS[notification.type];
|
||||
const duration = notification.duration ?? 4000;
|
||||
|
||||
/* ── Enter animation ── */
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
/* ── Progress bar ── */
|
||||
useEffect(() => {
|
||||
if (duration <= 0) return;
|
||||
const start = Date.now();
|
||||
const frame = () => {
|
||||
const elapsed = Date.now() - start;
|
||||
const pct = Math.max(0, 100 - (elapsed / duration) * 100);
|
||||
setProgress(pct);
|
||||
if (pct > 0) requestAnimationFrame(frame);
|
||||
};
|
||||
const raf = requestAnimationFrame(frame);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [duration]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={`
|
||||
flex items-start gap-3 p-4 rounded-xl glass border border-surface-700 shadow-xl
|
||||
transition-all duration-300 ease-out max-w-sm
|
||||
${visible ? 'translate-x-0 opacity-100' : 'translate-x-8 opacity-0'}
|
||||
`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<Icon className={`w-5 h-5 ${color}`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-surface-200">{notification.title}</p>
|
||||
{notification.message && (
|
||||
<p className="text-xs text-surface-400 mt-0.5 break-words">{notification.message}</p>
|
||||
)}
|
||||
{/* Progress bar */}
|
||||
{duration > 0 && (
|
||||
<div className="mt-2 h-0.5 rounded-full bg-surface-700 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-none ${color.replace('text-', 'bg-')}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={() => onDismiss(notification.id)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-surface-700 transition-colors"
|
||||
aria-label="Sluiten"
|
||||
>
|
||||
<X className="w-4 h-4 text-surface-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useNotify } from '@/lib/notifications';
|
||||
import Toast from './Toast';
|
||||
|
||||
/* ════════════════════════════ Toast Container ════════════════════════════ */
|
||||
|
||||
export default function ToastContainer() {
|
||||
const { notifications, dismiss } = useNotify();
|
||||
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none"
|
||||
aria-live="polite"
|
||||
aria-label="Notificaties"
|
||||
>
|
||||
{notifications.map((n) => (
|
||||
<div key={n.id} className="pointer-events-auto">
|
||||
<Toast notification={n} onDismiss={dismiss} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatBytes, gatewayLink, truncateCid } from '../helpers';
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats 0 bytes', () => {
|
||||
expect(formatBytes(0)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('formats bytes without decimal', () => {
|
||||
expect(formatBytes(512)).toBe('512 B');
|
||||
});
|
||||
|
||||
it('formats KB with one decimal when < 10', () => {
|
||||
expect(formatBytes(1536)).toBe('1.5 KB');
|
||||
});
|
||||
|
||||
it('formats KB as integer when >= 10', () => {
|
||||
expect(formatBytes(15360)).toBe('15 KB');
|
||||
});
|
||||
|
||||
it('formats MB with one decimal when < 10', () => {
|
||||
expect(formatBytes(1048576)).toBe('1.0 MB');
|
||||
});
|
||||
|
||||
it('formats GB with one decimal when < 10', () => {
|
||||
expect(formatBytes(1073741824)).toBe('1.0 GB');
|
||||
});
|
||||
|
||||
it('formats TB with one decimal when < 10', () => {
|
||||
expect(formatBytes(1099511627776)).toBe('1.0 TB');
|
||||
});
|
||||
|
||||
it('formats large value as integer when >= 10', () => {
|
||||
// 10+ MB → integer, no decimal
|
||||
expect(formatBytes(11534336)).toBe('11 MB');
|
||||
});
|
||||
|
||||
it('handles negative values gracefully', () => {
|
||||
const result = formatBytes(-100);
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gatewayLink', () => {
|
||||
it('returns correct gateway URL for valid CID', () => {
|
||||
const cid = 'QmTest123';
|
||||
expect(gatewayLink(cid)).toBe('https://maos.dedyn.io/ipfs/QmTest123');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(gatewayLink('')).toBe('https://maos.dedyn.io/ipfs/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateCid', () => {
|
||||
it('returns full CID when shorter than chars', () => {
|
||||
expect(truncateCid('abc', 12)).toBe('abc');
|
||||
});
|
||||
|
||||
it('truncates long CID with ellipsis', () => {
|
||||
const cid = 'QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco';
|
||||
expect(truncateCid(cid, 12)).toBe('QmXoypizjW3W...');
|
||||
});
|
||||
|
||||
it('supports custom chars limit', () => {
|
||||
const cid = 'QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco';
|
||||
expect(truncateCid(cid, 6)).toBe('QmXoyp...');
|
||||
});
|
||||
|
||||
it('returns full CID when equal to chars', () => {
|
||||
expect(truncateCid('abcdef', 6)).toBe('abcdef');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(truncateCid('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { checkUploadAllowed, formatQuotaResult } from '../limits';
|
||||
|
||||
// Mock API calls — these functions hit live endpoints in production
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getRepoStats: vi.fn().mockRejectedValue(new Error('Not available')),
|
||||
listPins: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/payment', () => ({
|
||||
paymentService: {
|
||||
getUserUploads: vi.fn().mockRejectedValue(new Error('No contract')),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('checkUploadAllowed', () => {
|
||||
it('allows upload when usage is within limits', async () => {
|
||||
const result = await checkUploadAllowed();
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows upload with wallet when no contract data', async () => {
|
||||
const result = await checkUploadAllowed('0x1234567890123456789012345678901234567890');
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('applies custom quota override', async () => {
|
||||
// maxStorageMB=1 means 0 < 1 so still allowed when Kubo API fails
|
||||
const result = await checkUploadAllowed(undefined, { maxStorageMB: 1 });
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns correct default quota values', async () => {
|
||||
const result = await checkUploadAllowed();
|
||||
expect(result.quota.maxStorageMB).toBe(30_720);
|
||||
expect(result.quota.maxUploads).toBe(1_000);
|
||||
expect(result.quota.freeUploadsPerDay).toBe(50);
|
||||
expect(result.quota.maxPins).toBe(10_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatQuotaResult', () => {
|
||||
it('returns positive message when allowed', () => {
|
||||
const result = {
|
||||
allowed: true,
|
||||
quota: { maxStorageMB: 100, maxUploads: 10, freeUploadsPerDay: 5, maxPins: 100 },
|
||||
current: { storageUsedMB: 10, uploadCount: 2, pinCount: 3, todayUploadCount: 1 },
|
||||
};
|
||||
expect(formatQuotaResult(result as any)).toBe('Upload toegestaan');
|
||||
});
|
||||
|
||||
it('returns warning when blocked', () => {
|
||||
const result = {
|
||||
allowed: false,
|
||||
reason: 'Opslaglimiet bereikt',
|
||||
quota: { maxStorageMB: 100, maxUploads: 10, freeUploadsPerDay: 5, maxPins: 100 },
|
||||
current: { storageUsedMB: 100, uploadCount: 10, pinCount: 100, todayUploadCount: 5 },
|
||||
};
|
||||
expect(formatQuotaResult(result as any)).toBe('⚠️ Opslaglimiet bereikt');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isTextFile,
|
||||
extractText,
|
||||
addToIndex,
|
||||
removeFromIndex,
|
||||
clearIndex,
|
||||
getIndexStats,
|
||||
searchIndex,
|
||||
type IndexedEntry,
|
||||
} from '../search-index';
|
||||
|
||||
/* ── Note: search-index.ts gebruikt localStorage.
|
||||
* In vitest (node env) is localStorage niet beschikbaar.
|
||||
* De pure functies (isTextFile, extractText) testen we direct.
|
||||
* De storage-functies testen we alleen de logica.
|
||||
*/
|
||||
|
||||
describe('isTextFile', () => {
|
||||
it('recognises .txt', () => {
|
||||
expect(isTextFile('readme.txt')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .md', () => {
|
||||
expect(isTextFile('CHANGELOG.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .json', () => {
|
||||
expect(isTextFile('package.json')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .html', () => {
|
||||
expect(isTextFile('index.html')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .csv', () => {
|
||||
expect(isTextFile('data.csv')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .log', () => {
|
||||
expect(isTextFile('error.log')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .xml', () => {
|
||||
expect(isTextFile('config.xml')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises .yml', () => {
|
||||
expect(isTextFile('docker-compose.yml')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects .png', () => {
|
||||
expect(isTextFile('photo.png')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects .jpg', () => {
|
||||
expect(isTextFile('image.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects no-extension', () => {
|
||||
expect(isTextFile('Makefile')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractText', () => {
|
||||
it('preserves plain text', () => {
|
||||
expect(extractText('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('strips HTML tags', () => {
|
||||
expect(extractText('<p>Hello <b>world</b></p>')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('collapses whitespace', () => {
|
||||
expect(extractText('hello world\n\n foo')).toBe('hello world foo');
|
||||
});
|
||||
|
||||
it('strips JSON braces', () => {
|
||||
expect(extractText('{"key": "value"}')).toBe('"key": "value"');
|
||||
});
|
||||
|
||||
it('limits to 2048 chars', () => {
|
||||
const long = 'a'.repeat(3000);
|
||||
expect(extractText(long).length).toBe(2048);
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(extractText('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles complex HTML', () => {
|
||||
const html = `<!DOCTYPE html><html><head><title>Test</title></head>
|
||||
<body><h1>Hello</h1><p>This is a <a href="#">test</a> page.</p></body></html>`;
|
||||
expect(extractText(html)).toContain('Hello');
|
||||
expect(extractText(html)).toContain('This is a test page');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchIndex scoring (no localStorage)', () => {
|
||||
it('searchIndex returns empty for short query', () => {
|
||||
const results = searchIndex('x');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('searchIndex returns empty for empty query', () => {
|
||||
const results = searchIndex('');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('searchIndex returns empty when no index exists', () => {
|
||||
const results = searchIndex('hello');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage functions (no-op in node env)', () => {
|
||||
it('addToIndex does not throw', () => {
|
||||
const entry: IndexedEntry = {
|
||||
cid: 'QmTest',
|
||||
name: 'test.txt',
|
||||
type: 'file',
|
||||
text: 'hello world',
|
||||
size: 11,
|
||||
indexedAt: Date.now(),
|
||||
};
|
||||
expect(() => addToIndex(entry)).not.toThrow();
|
||||
});
|
||||
|
||||
it('removeFromIndex does not throw', () => {
|
||||
expect(() => removeFromIndex('QmTest')).not.toThrow();
|
||||
});
|
||||
|
||||
it('clearIndex does not throw', () => {
|
||||
expect(() => clearIndex()).not.toThrow();
|
||||
});
|
||||
|
||||
it('getIndexStats returns zeros', () => {
|
||||
// localStorage schrijft nergens heen in node env
|
||||
const stats = getIndexStats();
|
||||
expect(stats).toHaveProperty('entries');
|
||||
expect(stats).toHaveProperty('totalChars');
|
||||
expect(stats).toHaveProperty('lastIndexed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatWeiToETH, formatBytes, CHAIN_IDS } from '../wallet';
|
||||
|
||||
describe('formatWeiToETH', () => {
|
||||
it('formats 0 wei', () => {
|
||||
expect(formatWeiToETH(0n)).toBe('0.0000');
|
||||
});
|
||||
|
||||
it('formats 1 ETH', () => {
|
||||
expect(formatWeiToETH(10n ** 18n)).toBe('1.0000');
|
||||
});
|
||||
|
||||
it('formats 1.5 ETH', () => {
|
||||
expect(formatWeiToETH(15n * 10n ** 17n)).toBe('1.5000');
|
||||
});
|
||||
|
||||
it('formats small values', () => {
|
||||
expect(formatWeiToETH(10n ** 14n)).toBe('0.0001');
|
||||
});
|
||||
|
||||
it('accepts string input', () => {
|
||||
expect(formatWeiToETH('1000000000000000000')).toBe('1.0000');
|
||||
});
|
||||
|
||||
it('respects custom decimals', () => {
|
||||
expect(formatWeiToETH(10n ** 18n, 2)).toBe('1.00');
|
||||
});
|
||||
|
||||
it('handles zero decimals', () => {
|
||||
expect(formatWeiToETH(5n * 10n ** 17n, 0)).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBytes (wallet)', () => {
|
||||
it('formats 0 bytes', () => {
|
||||
expect(formatBytes(0n)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('formats bytes', () => {
|
||||
expect(formatBytes(500n)).toBe('500 B');
|
||||
});
|
||||
|
||||
it('formats KB', () => {
|
||||
expect(formatBytes(1500n)).toBe('1.5 KB');
|
||||
});
|
||||
|
||||
it('formats MB', () => {
|
||||
expect(formatBytes(3n * 1024n * 1024n)).toBe('3.0 MB');
|
||||
});
|
||||
|
||||
it('formats GB', () => {
|
||||
expect(formatBytes(2n * 1024n * 1024n * 1024n)).toBe('2.00 GB');
|
||||
});
|
||||
|
||||
it('accepts number input', () => {
|
||||
expect(formatBytes(1024)).toBe('1.0 KB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHAIN_IDS', () => {
|
||||
it('has expected chain constants', () => {
|
||||
expect(CHAIN_IDS.ZKSYNC_LOCAL).toBe(270);
|
||||
expect(CHAIN_IDS.ETHEREUM).toBe(1);
|
||||
expect(CHAIN_IDS.SEPOLIA).toBe(11155111);
|
||||
});
|
||||
});
|
||||
+6
-2
@@ -149,8 +149,12 @@ export interface IPNSKey {
|
||||
}
|
||||
|
||||
export async function listIPNSKeys(): Promise<IPNSKey[]> {
|
||||
const res = await apiFetch<{ Keys: IPNSKey[] }>('/ipns/keys');
|
||||
return res.Keys || [];
|
||||
const res = await apiFetch<{ Keys: { Name?: string; Id?: string }[] }>('/ipns/keys');
|
||||
if (!res.Keys) return [];
|
||||
return res.Keys.map((k: { Name?: string; Id?: string }) => ({
|
||||
name: k.Name ?? '',
|
||||
id: k.Id ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
export async function ipnsPublish(cid: string, key = 'self'): Promise<{ name: string; value: string }> {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/* ── IPFS Portal Server Auth ──
|
||||
*
|
||||
* JWT-gebaseerde sessies met wallet (SIWE) ondersteuning.
|
||||
* De JWT slaat het Python backend session_token op, zodat
|
||||
* de API proxy het kan doorsturen als X-Session-Token header.
|
||||
*/
|
||||
|
||||
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
|
||||
|
||||
/* ════════════════════════════ Config ════════════════════════════ */
|
||||
|
||||
export const SESSION_COOKIE = 'ipfs-portal-session';
|
||||
export const SESSION_MAX_AGE_SEC = 24 * 60 * 60; // 24 uur
|
||||
|
||||
function getSecret(): Uint8Array {
|
||||
const secret = process.env.JWT_SECRET || 'ipfs-portal-dev-secret-change-in-production';
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Types ════════════════════════════ */
|
||||
|
||||
export interface SessionPayload {
|
||||
address: string;
|
||||
role: 'admin' | 'user';
|
||||
sessionToken: string; // Python backend session token (UUID)
|
||||
}
|
||||
|
||||
export type SessionResult =
|
||||
| { authenticated: true; address: string; role: 'admin' | 'user'; sessionToken: string }
|
||||
| { authenticated: false };
|
||||
|
||||
/* ════════════════════════════ JWT Helpers ════════════════════════════ */
|
||||
|
||||
export async function createSessionJWT(payload: SessionPayload): Promise<string> {
|
||||
return new SignJWT({ ...payload } as unknown as JWTPayload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${SESSION_MAX_AGE_SEC}s`)
|
||||
.sign(getSecret());
|
||||
}
|
||||
|
||||
export async function verifySessionJWT(token: string): Promise<SessionPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getSecret(), {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
const { address, role, sessionToken } = payload as unknown as SessionPayload;
|
||||
if (!address || !role || !sessionToken) return null;
|
||||
return { address, role, sessionToken };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Cookie Helpers ════════════════════════════ */
|
||||
|
||||
export function cookieOptions(): {
|
||||
httpOnly: true;
|
||||
secure: boolean;
|
||||
sameSite: 'lax';
|
||||
maxAge: number;
|
||||
path: string;
|
||||
} {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_MAX_AGE_SEC,
|
||||
path: '/',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import type { WalletProvider } from '@/lib/wallet';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface AuthUser {
|
||||
address: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
export interface WalletOption {
|
||||
name: string;
|
||||
icon?: string;
|
||||
provider: WalletProvider;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
loginWithWallet: (address: string, provider: WalletProvider) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
isAdmin: boolean;
|
||||
connectError: string | null;
|
||||
}
|
||||
|
||||
/* ── Context ── */
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
/* ── Provider ── */
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
|
||||
/* Check session on mount */
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.authenticated) {
|
||||
setUser({ address: data.address, role: data.role });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* server unreachable — stay logged out */
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const loginWithWallet = useCallback(async (address: string, provider: WalletProvider) => {
|
||||
setConnectError(null);
|
||||
|
||||
// 1. Get SIWE challenge message
|
||||
let challengeRes: Response;
|
||||
try {
|
||||
challengeRes = await fetch('/api/auth/challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address }),
|
||||
});
|
||||
} catch {
|
||||
setConnectError('Kan geen verbinding maken met de server');
|
||||
throw new Error('Network error');
|
||||
}
|
||||
|
||||
if (!challengeRes.ok) {
|
||||
const data = await challengeRes.json().catch(() => ({}));
|
||||
const msg = data.error || 'Challenge mislukt';
|
||||
setConnectError(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const { message, nonce } = await challengeRes.json();
|
||||
|
||||
// 2. Sign the message with the wallet
|
||||
let signature: string;
|
||||
try {
|
||||
signature = await provider.request({
|
||||
method: 'personal_sign',
|
||||
params: [message, address],
|
||||
});
|
||||
} catch {
|
||||
setConnectError('Signatuur geweigerd in wallet');
|
||||
throw new Error('User rejected signature');
|
||||
}
|
||||
|
||||
// 3. Send signed message to backend for verification
|
||||
let loginRes: Response;
|
||||
try {
|
||||
loginRes = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address, signature, nonce }),
|
||||
});
|
||||
} catch {
|
||||
setConnectError('Login request mislukt');
|
||||
throw new Error('Login network error');
|
||||
}
|
||||
|
||||
if (!loginRes.ok) {
|
||||
const data = await loginRes.json().catch(() => ({}));
|
||||
const msg = data.error || 'Verificatie mislukt';
|
||||
setConnectError(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const data = await loginRes.json();
|
||||
setUser({ address: data.address, role: data.role });
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, loginWithWallet, logout, isAdmin: user?.role === 'admin', connectError }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Hook ── */
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within <AuthProvider>');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* ════════════════════════════ Download ════════════════════════════
|
||||
* Utility om IPFS content te downloaden als bestand.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Download een bestand van IPFS via de explorer/cat proxy.
|
||||
* Maakt een Blob van de response en simuleert een klik op <a download>.
|
||||
*/
|
||||
export async function downloadFile(cid: string, filename?: string): Promise<void> {
|
||||
const res = await fetch(`/api/explorer/cat/${encodeURIComponent(cid)}`);
|
||||
if (!res.ok) throw new Error(`Download mislukt (${res.status})`);
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const ext = filename?.includes('.') ? '' : '.bin';
|
||||
const name = filename || `${cid.slice(0, 12)}${ext}`;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download via gateway (alternatief als proxy traag is).
|
||||
*/
|
||||
export function downloadViaGateway(cid: string, filename?: string) {
|
||||
const gateway = process.env.NEXT_PUBLIC_GATEWAY_URL || 'https://maos.dedyn.io';
|
||||
const a = document.createElement('a');
|
||||
a.href = `${gateway}/ipfs/${cid}`;
|
||||
a.download = filename || cid;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.click();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* ── Format bytes to human-readable size ── */
|
||||
export 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];
|
||||
}
|
||||
|
||||
/* ── Gateway link for CID ── */
|
||||
export function gatewayLink(cid: string): string {
|
||||
return `https://maos.dedyn.io/ipfs/${cid}`;
|
||||
}
|
||||
|
||||
/* ── Truncate CID for display ── */
|
||||
export function truncateCid(cid: string, chars: number = 12): string {
|
||||
if (cid.length <= chars) return cid;
|
||||
return `${cid.slice(0, chars)}...`;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/* ── Usage Limits ──
|
||||
*
|
||||
* Defines per-user/wallet quotas and checks current usage against them.
|
||||
* Uses Kubo API stats + contract data to determine if an upload is allowed.
|
||||
*/
|
||||
|
||||
import { getNodeInfo, getRepoStats, listPins } from './api';
|
||||
|
||||
/* ════════════════════════════ Types ════════════════════════════ */
|
||||
|
||||
export interface UsageQuota {
|
||||
maxStorageMB: number; // Max repo size in MB
|
||||
maxUploads: number; // Max total uploads
|
||||
freeUploadsPerDay: number; // Free tier: max uploads per day
|
||||
maxPins: number; // Max pinned CIDs
|
||||
}
|
||||
|
||||
export interface UsageCurrent {
|
||||
storageUsedMB: number;
|
||||
uploadCount: number;
|
||||
pinCount: number;
|
||||
todayUploadCount: number;
|
||||
}
|
||||
|
||||
export interface UsageCheckResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
quota: UsageQuota;
|
||||
current: UsageCurrent;
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Defaults ════════════════════════════ */
|
||||
|
||||
const DEFAULT_QUOTA: UsageQuota = {
|
||||
maxStorageMB: 30_720, // 30 GB
|
||||
maxUploads: 1_000,
|
||||
freeUploadsPerDay: 50,
|
||||
maxPins: 10_000,
|
||||
};
|
||||
|
||||
/* ════════════════════════════ Check ════════════════════════════ */
|
||||
|
||||
export async function checkUploadAllowed(
|
||||
wallet?: string,
|
||||
quotaOverride?: Partial<UsageQuota>,
|
||||
): Promise<UsageCheckResult> {
|
||||
const quota: UsageQuota = { ...DEFAULT_QUOTA, ...quotaOverride };
|
||||
const current: UsageCurrent = {
|
||||
storageUsedMB: 0,
|
||||
uploadCount: 0,
|
||||
pinCount: 0,
|
||||
todayUploadCount: 0,
|
||||
};
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
/* ── Gather current usage ── */
|
||||
|
||||
// 1. Storage from Kubo repo stat
|
||||
try {
|
||||
const repo = await getRepoStats();
|
||||
current.storageUsedMB = Math.round(repo.repoSize / (1024 * 1024));
|
||||
current.pinCount = repo.numObjects;
|
||||
} catch {
|
||||
// Kubo unreachable — allow through, log elsewhere
|
||||
}
|
||||
|
||||
// 2. Pin count
|
||||
try {
|
||||
const pins = await listPins();
|
||||
current.pinCount = pins.length;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 3. On-chain upload count (if wallet provided)
|
||||
if (wallet) {
|
||||
try {
|
||||
const { paymentService } = await import('@/lib/payment');
|
||||
const stats = await paymentService.getUserUploads(wallet as `0x${string}`);
|
||||
current.uploadCount = stats.uploadCount;
|
||||
|
||||
// Count today's uploads
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayTs = BigInt(Math.floor(today.getTime() / 1000));
|
||||
current.todayUploadCount = stats.uploads.filter(
|
||||
(u) => u.timestamp >= todayTs,
|
||||
).length;
|
||||
} catch {
|
||||
// Contract unreachable — assume wallet has no on-chain data
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Check limits ── */
|
||||
|
||||
if (current.storageUsedMB >= quota.maxStorageMB) {
|
||||
const usedGb = (current.storageUsedMB / 1024).toFixed(1);
|
||||
const maxGb = (quota.maxStorageMB / 1024).toFixed(1);
|
||||
errors.push(`Opslaglimiet bereikt (${usedGb}/${maxGb} GB)`);
|
||||
}
|
||||
|
||||
if (current.pinCount >= quota.maxPins) {
|
||||
errors.push(`Maximaal aantal pins bereikt (${quota.maxPins})`);
|
||||
}
|
||||
|
||||
if (current.uploadCount >= quota.maxUploads) {
|
||||
errors.push(`Maximaal aantal uploads bereikt (${quota.maxUploads})`);
|
||||
}
|
||||
|
||||
if (current.todayUploadCount >= quota.freeUploadsPerDay) {
|
||||
errors.push(`Dagelijkse gratis limiet bereikt (${quota.freeUploadsPerDay}/dag)`);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: errors.length === 0,
|
||||
reason: errors.length > 0 ? errors.join('; ') : undefined,
|
||||
quota,
|
||||
current,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Format helper ── */
|
||||
|
||||
export function formatQuotaResult(result: UsageCheckResult): string {
|
||||
if (result.allowed) return 'Upload toegestaan';
|
||||
return `⚠️ ${result.reason}`;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export type NotificationType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number; // ms, default 4000
|
||||
}
|
||||
|
||||
interface NotificationContextValue {
|
||||
notifications: Notification[];
|
||||
notify: (n: Omit<Notification, 'id'>) => string;
|
||||
dismiss: (id: string) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/* ── Context ── */
|
||||
|
||||
const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||
|
||||
/* ── Provider ── */
|
||||
|
||||
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
}, []);
|
||||
|
||||
const notify = useCallback(
|
||||
(n: Omit<Notification, 'id'>): string => {
|
||||
const id = `ntf-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const notification: Notification = { ...n, id, duration: n.duration ?? 4000 };
|
||||
|
||||
setNotifications((prev) => [...prev, notification]);
|
||||
|
||||
const dur = notification.duration ?? 5000;
|
||||
if (dur > 0) {
|
||||
setTimeout(() => dismiss(id), dur);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setNotifications([]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ notifications, notify, dismiss, clear }}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Hook ── */
|
||||
|
||||
export function useNotify(): NotificationContextValue {
|
||||
const ctx = useContext(NotificationContext);
|
||||
if (!ctx) throw new Error('useNotify must be used within <NotificationProvider>');
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
/* ── IPFS Portal Payment Service ── */
|
||||
|
||||
import {
|
||||
createWalletClient,
|
||||
createPublicClient,
|
||||
custom,
|
||||
http,
|
||||
encodeFunctionData,
|
||||
type Chain,
|
||||
type Address,
|
||||
type Hash,
|
||||
} from 'viem';
|
||||
import { getInjectedProvider, type WalletProvider, type WalletState } from './wallet';
|
||||
|
||||
/* ── Chain 270 (zkSync Local) ── */
|
||||
export const zkSyncLocal: Chain = {
|
||||
id: 270,
|
||||
name: 'zkSync Local',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: {
|
||||
default: { http: ['http://tom1687.no-ip.org:3050'] },
|
||||
},
|
||||
blockExplorers: {
|
||||
default: { name: 'Explorer', url: 'http://tom1687.no-ip.org:3050' },
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Contract ABI ── */
|
||||
export const IPFS_PORTAL_PAYMENT_ABI = [
|
||||
// ── State ──
|
||||
{ type: 'function', name: 'owner', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'maosTokenAddress', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'freeTierBytes', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'basePricePerMB', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'userTotalBytes', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'totalRevenue', inputs: [{ type: 'string' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'totalUploads', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'supportedTokens', inputs: [{ type: 'uint256' }], outputs: [{ type: 'string' }], stateMutability: 'view' },
|
||||
|
||||
// ── View ──
|
||||
{ type: 'function', name: 'getPrice', inputs: [{ type: 'uint256' }, { type: 'address' }], outputs: [
|
||||
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'bool' }
|
||||
], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'getUserStats', inputs: [{ type: 'address' }], outputs: [
|
||||
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' },
|
||||
{ type: 'tuple[]', components: [
|
||||
{ type: 'string' }, { type: 'uint256' }, { type: 'string' }, { type: 'uint256' }, { type: 'uint256' }
|
||||
]}
|
||||
], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'getMAOSDiscountTiers', inputs: [], outputs: [
|
||||
{ type: 'tuple[]', components: [{ type: 'uint256' }, { type: 'uint256' }] }
|
||||
], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'getSupportedTokens', inputs: [], outputs: [{ type: 'string[]' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'tokenConfigs', inputs: [{ type: 'string' }], outputs: [
|
||||
{ type: 'address' }, { type: 'uint8' }, { type: 'uint256' }, { type: 'bool' }
|
||||
], stateMutability: 'view' },
|
||||
|
||||
// ── Write ──
|
||||
{ type: 'function', name: 'payWithETH', inputs: [{ type: 'string' }, { type: 'uint256' }], outputs: [], stateMutability: 'payable' },
|
||||
{ type: 'function', name: 'payWithToken', inputs: [{ type: 'string' }, { type: 'uint256' }, { type: 'string' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
|
||||
// ── Admin Write ──
|
||||
{ type: 'function', name: 'setPricePerMB', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'setFreeTierBytes', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'setMAOSDiscountTier', inputs: [{ type: 'uint256' }, { type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'removeMAOSDiscountTier', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'configureToken', inputs: [{ type: 'string' }, { type: 'address' }, { type: 'uint8' }, { type: 'uint256' }, { type: 'bool' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'setMAOSTokenAddress', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'withdrawETH', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'withdrawToken', inputs: [{ type: 'string' }, { type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'transferOwnership', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
|
||||
// ── Events ──
|
||||
{ type: 'event', name: 'UploadPaidETH', inputs: [
|
||||
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
|
||||
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }
|
||||
]},
|
||||
{ type: 'event', name: 'UploadPaidToken', inputs: [
|
||||
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
|
||||
{ type: 'uint256' }, { type: 'string', indexed: true }, { type: 'uint256' }, { type: 'uint256' }
|
||||
]},
|
||||
{ type: 'event', name: 'FreeUploadUsed', inputs: [
|
||||
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
|
||||
{ type: 'uint256' }, { type: 'uint256' }
|
||||
]},
|
||||
] as const;
|
||||
|
||||
/* ── Default contract address (set after deploy) ── */
|
||||
export const DEFAULT_CONTRACT_ADDRESS = '0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe';
|
||||
|
||||
/* ── ERC20 ABI (minimal for approve + balanceOf) ── */
|
||||
const ERC20_ABI = [
|
||||
{ type: 'function', name: 'balanceOf', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'approve', inputs: [{ type: 'address' }, { type: 'uint256' }], outputs: [{ type: 'bool' }], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'allowance', inputs: [{ type: 'address' }, { type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'decimals', inputs: [], outputs: [{ type: 'uint8' }], stateMutability: 'view' },
|
||||
] as const;
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface PriceInfo {
|
||||
totalWei: bigint;
|
||||
discountBps: number;
|
||||
finalWei: bigint;
|
||||
isFree: boolean;
|
||||
}
|
||||
|
||||
export interface UserUploadStats {
|
||||
totalBytes: bigint;
|
||||
uploadCount: number;
|
||||
remainingFree: bigint;
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
symbol: string;
|
||||
address: Address;
|
||||
decimals: number;
|
||||
weiPerToken: bigint;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface MAOSDiscountTier {
|
||||
minBalance: bigint;
|
||||
discountBps: number;
|
||||
}
|
||||
|
||||
export interface UploadRecord {
|
||||
cid: string;
|
||||
bytesSize: bigint;
|
||||
tokenSymbol: string;
|
||||
amountPaid: bigint;
|
||||
timestamp: bigint;
|
||||
}
|
||||
|
||||
export interface UserFullStats {
|
||||
totalBytes: bigint;
|
||||
uploadCount: number;
|
||||
remainingFree: bigint;
|
||||
uploads: UploadRecord[];
|
||||
}
|
||||
|
||||
/* ── PaymentService ── */
|
||||
|
||||
export class PaymentService {
|
||||
private contractAddress: Address;
|
||||
|
||||
constructor(contractAddress?: string) {
|
||||
this.contractAddress = (contractAddress || DEFAULT_CONTRACT_ADDRESS) as Address;
|
||||
}
|
||||
|
||||
setContractAddress(address: string) {
|
||||
this.contractAddress = address as Address;
|
||||
}
|
||||
|
||||
/* ── Create public client (read-only) ── */
|
||||
private getPublicClient() {
|
||||
return createPublicClient({
|
||||
chain: zkSyncLocal,
|
||||
transport: http(),
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Create wallet client from provider ── */
|
||||
private getWalletClient(provider: WalletProvider) {
|
||||
return createWalletClient({
|
||||
chain: zkSyncLocal,
|
||||
transport: custom(provider),
|
||||
});
|
||||
}
|
||||
|
||||
/* ── READ: Get price for upload ── */
|
||||
async getPrice(bytesSize: number, userAddress: Address): Promise<PriceInfo> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getPrice',
|
||||
args: [BigInt(bytesSize), userAddress],
|
||||
});
|
||||
return {
|
||||
totalWei: result[0] as bigint,
|
||||
discountBps: Number(result[1]),
|
||||
finalWei: result[2] as bigint,
|
||||
isFree: result[3] as boolean,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── READ: Get user upload stats ── */
|
||||
async getUserStats(userAddress: Address): Promise<UserUploadStats> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getUserStats',
|
||||
args: [userAddress],
|
||||
});
|
||||
return {
|
||||
totalBytes: result[0] as bigint,
|
||||
uploadCount: Number(result[1]),
|
||||
remainingFree: result[2] as bigint,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── READ: Get full user stats including upload records ── */
|
||||
async getUserUploads(userAddress: Address): Promise<UserFullStats> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getUserStats',
|
||||
args: [userAddress],
|
||||
}) as unknown as readonly [bigint, bigint, bigint, readonly (readonly [string, bigint, string, bigint, bigint])[]];
|
||||
|
||||
const records = result[3] as unknown as readonly (readonly [string, bigint, string, bigint, bigint])[] ?? [];
|
||||
const uploads: UploadRecord[] = Array.from(records).map((rec) => ({
|
||||
cid: rec[0],
|
||||
bytesSize: rec[1],
|
||||
tokenSymbol: rec[2],
|
||||
amountPaid: rec[3],
|
||||
timestamp: rec[4],
|
||||
}));
|
||||
|
||||
return {
|
||||
totalBytes: result[0],
|
||||
uploadCount: Number(result[1]),
|
||||
remainingFree: result[2],
|
||||
uploads,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── READ: Get supported tokens ── */
|
||||
async getSupportedTokens(): Promise<TokenInfo[]> {
|
||||
const client = this.getPublicClient();
|
||||
const symbols = await client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getSupportedTokens',
|
||||
args: [],
|
||||
}) as string[];
|
||||
|
||||
const tokens: TokenInfo[] = [];
|
||||
for (const symbol of symbols) {
|
||||
const config = await client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'tokenConfigs',
|
||||
args: [symbol],
|
||||
});
|
||||
tokens.push({
|
||||
symbol,
|
||||
address: (config as any)[0] as Address,
|
||||
decimals: (config as any)[1] as number,
|
||||
weiPerToken: (config as any)[2] as bigint,
|
||||
enabled: (config as any)[3] as boolean,
|
||||
});
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/* ── READ: Get MAOS discount tiers ── */
|
||||
async getMAOSDiscountTiers(): Promise<MAOSDiscountTier[]> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getMAOSDiscountTiers',
|
||||
args: [],
|
||||
}) as unknown;
|
||||
|
||||
const tiers = result as readonly (readonly [bigint, bigint])[];
|
||||
return Array.from(tiers).map(([minBalance, discountBps]) => ({
|
||||
minBalance,
|
||||
discountBps: Number(discountBps),
|
||||
}));
|
||||
}
|
||||
|
||||
/* ── READ: Get free tier bytes ── */
|
||||
async getFreeTierBytes(): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'freeTierBytes',
|
||||
args: [],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get base price per MB ── */
|
||||
async getBasePricePerMB(): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'basePricePerMB',
|
||||
args: [],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── WRITE: Pay with ETH ── */
|
||||
async payWithETH(
|
||||
provider: WalletProvider,
|
||||
cid: string,
|
||||
bytesSize: number,
|
||||
priceWei: bigint,
|
||||
): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
|
||||
const hash = await walletClient.sendTransaction({
|
||||
account: address,
|
||||
to: this.contractAddress,
|
||||
value: priceWei,
|
||||
data: this.encodePayWithETH(cid, bytesSize),
|
||||
});
|
||||
return hash;
|
||||
}
|
||||
|
||||
/* ── WRITE: Approve ERC20 + Pay with token ── */
|
||||
async approveAndPayWithToken(
|
||||
provider: WalletProvider,
|
||||
tokenAddress: Address,
|
||||
cid: string,
|
||||
bytesSize: number,
|
||||
tokenAmount: bigint,
|
||||
tokenSymbol: string,
|
||||
): Promise<{ approveHash?: Hash; payHash: Hash }> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
|
||||
// Check allowance
|
||||
const publicClient = this.getPublicClient();
|
||||
const allowance = await publicClient.readContract({
|
||||
address: tokenAddress,
|
||||
abi: ERC20_ABI,
|
||||
functionName: 'allowance',
|
||||
args: [address, this.contractAddress],
|
||||
}) as bigint;
|
||||
|
||||
let approveHash: Hash | undefined;
|
||||
if (allowance < tokenAmount) {
|
||||
const approveReq = await walletClient.writeContract({
|
||||
account: address,
|
||||
address: tokenAddress,
|
||||
abi: ERC20_ABI,
|
||||
functionName: 'approve',
|
||||
args: [this.contractAddress, tokenAmount],
|
||||
});
|
||||
approveHash = approveReq;
|
||||
}
|
||||
|
||||
// Pay
|
||||
const payHash = await walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'payWithToken',
|
||||
args: [cid, BigInt(bytesSize), tokenSymbol],
|
||||
});
|
||||
|
||||
return { approveHash, payHash };
|
||||
}
|
||||
|
||||
/* ── Encode payWithETH data ── */
|
||||
private encodePayWithETH(cid: string, bytesSize: number): `0x${string}` {
|
||||
return encodeFunctionData({
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'payWithETH',
|
||||
args: [cid, BigInt(bytesSize)],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── READ: Get contract owner ── */
|
||||
async getOwner(): Promise<Address> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'owner',
|
||||
args: [],
|
||||
}) as Promise<Address>;
|
||||
}
|
||||
|
||||
/* ── READ: Get total revenue for a token ── */
|
||||
async getTotalRevenue(tokenSymbol: string): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'totalRevenue',
|
||||
args: [tokenSymbol],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get total upload count ── */
|
||||
async getTotalUploads(): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'totalUploads',
|
||||
args: [],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get supported token symbols (string[]) ── */
|
||||
async getTokenSymbols(): Promise<string[]> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getSupportedTokens',
|
||||
args: [],
|
||||
}) as Promise<string[]>;
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — set price per MB (onlyOwner) ── */
|
||||
async adminSetPricePerMB(provider: WalletProvider, priceWei: bigint): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'setPricePerMB',
|
||||
args: [priceWei],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — set free tier bytes (onlyOwner) ── */
|
||||
async adminSetFreeTierBytes(provider: WalletProvider, bytes: bigint): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'setFreeTierBytes',
|
||||
args: [bytes],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — add/update MAOS discount tier ── */
|
||||
async adminSetDiscountTier(provider: WalletProvider, minBalance: bigint, discountBps: number): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'setMAOSDiscountTier',
|
||||
args: [minBalance, BigInt(discountBps)],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — remove MAOS discount tier ── */
|
||||
async adminRemoveDiscountTier(provider: WalletProvider, index: number): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'removeMAOSDiscountTier',
|
||||
args: [BigInt(index)],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — configure token ── */
|
||||
async adminConfigureToken(
|
||||
provider: WalletProvider,
|
||||
symbol: string,
|
||||
tokenAddress: Address,
|
||||
decimals: number,
|
||||
weiPerToken: bigint,
|
||||
enabled: boolean,
|
||||
): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'configureToken',
|
||||
args: [symbol, tokenAddress, decimals, weiPerToken, enabled],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — withdraw ETH (onlyOwner) ── */
|
||||
async adminWithdrawETH(provider: WalletProvider, to: Address): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'withdrawETH',
|
||||
args: [to],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — withdraw token (onlyOwner) ── */
|
||||
async adminWithdrawToken(provider: WalletProvider, tokenSymbol: string, to: Address): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'withdrawToken',
|
||||
args: [tokenSymbol, to],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — transfer ownership ── */
|
||||
async adminTransferOwnership(provider: WalletProvider, newOwner: Address): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address,
|
||||
address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'transferOwnership',
|
||||
args: [newOwner],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Check if contract is deployed ── */
|
||||
async isDeployed(): Promise<boolean> {
|
||||
if (this.contractAddress === DEFAULT_CONTRACT_ADDRESS) return false;
|
||||
try {
|
||||
const client = this.getPublicClient();
|
||||
const code = await client.getBytecode({ address: this.contractAddress });
|
||||
return code !== undefined && code !== '0x';
|
||||
} catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Singleton ── */
|
||||
export const paymentService = new PaymentService();
|
||||
@@ -0,0 +1,87 @@
|
||||
/* ── IPFS Portal Payment ABI & Constants ── */
|
||||
|
||||
import { type Chain } from 'viem';
|
||||
|
||||
/* ── Chain 270 (zkSync Local) ── */
|
||||
export const zkSyncLocal: Chain = {
|
||||
id: 270,
|
||||
name: 'zkSync Local',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: {
|
||||
default: { http: ['http://tom1687.no-ip.org:3050'] },
|
||||
},
|
||||
blockExplorers: {
|
||||
default: { name: 'Explorer', url: 'http://tom1687.no-ip.org:3050' },
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Contract ABI ── */
|
||||
export const IPFS_PORTAL_PAYMENT_ABI = [
|
||||
// ── State ──
|
||||
{ type: 'function', name: 'owner', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'maosTokenAddress', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'freeTierBytes', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'basePricePerMB', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'userTotalBytes', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'totalRevenue', inputs: [{ type: 'string' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'totalUploads', inputs: [], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'supportedTokens', inputs: [{ type: 'uint256' }], outputs: [{ type: 'string' }], stateMutability: 'view' },
|
||||
|
||||
// ── View ──
|
||||
{ type: 'function', name: 'getPrice', inputs: [{ type: 'uint256' }, { type: 'address' }], outputs: [
|
||||
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'bool' }
|
||||
], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'getUserStats', inputs: [{ type: 'address' }], outputs: [
|
||||
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' },
|
||||
{ type: 'tuple[]', components: [
|
||||
{ type: 'string' }, { type: 'uint256' }, { type: 'string' }, { type: 'uint256' }, { type: 'uint256' }
|
||||
]}
|
||||
], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'getMAOSDiscountTiers', inputs: [], outputs: [
|
||||
{ type: 'tuple[]', components: [{ type: 'uint256' }, { type: 'uint256' }] }
|
||||
], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'getSupportedTokens', inputs: [], outputs: [{ type: 'string[]' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'tokenConfigs', inputs: [{ type: 'string' }], outputs: [
|
||||
{ type: 'address' }, { type: 'uint8' }, { type: 'uint256' }, { type: 'bool' }
|
||||
], stateMutability: 'view' },
|
||||
|
||||
// ── Write ──
|
||||
{ type: 'function', name: 'payWithETH', inputs: [{ type: 'string' }, { type: 'uint256' }], outputs: [], stateMutability: 'payable' },
|
||||
{ type: 'function', name: 'payWithToken', inputs: [{ type: 'string' }, { type: 'uint256' }, { type: 'string' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
|
||||
// ── Admin Write ──
|
||||
{ type: 'function', name: 'setPricePerMB', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'setFreeTierBytes', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'setMAOSDiscountTier', inputs: [{ type: 'uint256' }, { type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'removeMAOSDiscountTier', inputs: [{ type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'configureToken', inputs: [{ type: 'string' }, { type: 'address' }, { type: 'uint8' }, { type: 'uint256' }, { type: 'bool' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'setMAOSTokenAddress', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'withdrawETH', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'withdrawToken', inputs: [{ type: 'string' }, { type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'transferOwnership', inputs: [{ type: 'address' }], outputs: [], stateMutability: 'nonpayable' },
|
||||
|
||||
// ── Events ──
|
||||
{ type: 'event', name: 'UploadPaidETH', inputs: [
|
||||
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
|
||||
{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }
|
||||
]},
|
||||
{ type: 'event', name: 'UploadPaidToken', inputs: [
|
||||
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
|
||||
{ type: 'uint256' }, { type: 'string', indexed: true }, { type: 'uint256' }, { type: 'uint256' }
|
||||
]},
|
||||
{ type: 'event', name: 'FreeUploadUsed', inputs: [
|
||||
{ type: 'address', indexed: true }, { type: 'string', indexed: true },
|
||||
{ type: 'uint256' }, { type: 'uint256' }
|
||||
]},
|
||||
] as const;
|
||||
|
||||
/* ── Default contract address (set after deploy) ── */
|
||||
export const DEFAULT_CONTRACT_ADDRESS = '0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe';
|
||||
|
||||
/* ── ERC20 ABI (minimal for approve + balanceOf) ── */
|
||||
export const ERC20_ABI = [
|
||||
{ type: 'function', name: 'balanceOf', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'approve', inputs: [{ type: 'address' }, { type: 'uint256' }], outputs: [{ type: 'bool' }], stateMutability: 'nonpayable' },
|
||||
{ type: 'function', name: 'allowance', inputs: [{ type: 'address' }, { type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' },
|
||||
{ type: 'function', name: 'decimals', inputs: [], outputs: [{ type: 'uint8' }], stateMutability: 'view' },
|
||||
] as const;
|
||||
@@ -0,0 +1,7 @@
|
||||
/* ── Barrel: IPFS Portal Payment ── */
|
||||
|
||||
export { IPFS_PORTAL_PAYMENT_ABI, DEFAULT_CONTRACT_ADDRESS, ERC20_ABI, zkSyncLocal } from './abi';
|
||||
export type { PriceInfo, UserUploadStats, TokenInfo, MAOSDiscountTier, UploadRecord, UserFullStats } from './types';
|
||||
export { PaymentService } from './write-service';
|
||||
export { PaymentReadService } from './read-service';
|
||||
export { paymentService } from './service';
|
||||
@@ -0,0 +1,154 @@
|
||||
/* ── IPFS Portal Payment Read Service ── */
|
||||
|
||||
import {
|
||||
createPublicClient, http,
|
||||
type Address,
|
||||
} from 'viem';
|
||||
import { zkSyncLocal, IPFS_PORTAL_PAYMENT_ABI, DEFAULT_CONTRACT_ADDRESS } from './abi';
|
||||
import type { PriceInfo, UserUploadStats, TokenInfo, MAOSDiscountTier, UserFullStats, UploadRecord } from './types';
|
||||
|
||||
export class PaymentReadService {
|
||||
protected contractAddress: Address;
|
||||
|
||||
constructor(contractAddress?: string) {
|
||||
this.contractAddress = (contractAddress || DEFAULT_CONTRACT_ADDRESS) as Address;
|
||||
}
|
||||
|
||||
setContractAddress(address: string) {
|
||||
this.contractAddress = address as Address;
|
||||
}
|
||||
|
||||
protected getPublicClient() {
|
||||
return createPublicClient({ chain: zkSyncLocal, transport: http() });
|
||||
}
|
||||
|
||||
/* ── READ: Get price for upload ── */
|
||||
async getPrice(bytesSize: number, userAddress: Address): Promise<PriceInfo> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getPrice', args: [BigInt(bytesSize), userAddress],
|
||||
});
|
||||
return {
|
||||
totalWei: result[0] as bigint, discountBps: Number(result[1]),
|
||||
finalWei: result[2] as bigint, isFree: result[3] as boolean,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── READ: Get user upload stats ── */
|
||||
async getUserStats(userAddress: Address): Promise<UserUploadStats> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getUserStats', args: [userAddress],
|
||||
});
|
||||
return {
|
||||
totalBytes: result[0] as bigint, uploadCount: Number(result[1]),
|
||||
remainingFree: result[2] as bigint,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── READ: Get full user stats including upload records ── */
|
||||
async getUserUploads(userAddress: Address): Promise<UserFullStats> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getUserStats', args: [userAddress],
|
||||
}) as unknown as readonly [bigint, bigint, bigint, readonly (readonly [string, bigint, string, bigint, bigint])[]];
|
||||
|
||||
const records = result[3] ?? [];
|
||||
const uploads: UploadRecord[] = Array.from(records).map(rec => ({
|
||||
cid: rec[0], bytesSize: rec[1], tokenSymbol: rec[2], amountPaid: rec[3], timestamp: rec[4],
|
||||
}));
|
||||
|
||||
return { totalBytes: result[0], uploadCount: Number(result[1]), remainingFree: result[2], uploads };
|
||||
}
|
||||
|
||||
/* ── READ: Get supported tokens ── */
|
||||
async getSupportedTokens(): Promise<TokenInfo[]> {
|
||||
const client = this.getPublicClient();
|
||||
const symbols = await client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getSupportedTokens', args: [],
|
||||
}) as string[];
|
||||
|
||||
const tokens: TokenInfo[] = [];
|
||||
for (const symbol of symbols) {
|
||||
const config = await client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'tokenConfigs', args: [symbol],
|
||||
});
|
||||
tokens.push({
|
||||
symbol, address: (config as any)[0] as Address,
|
||||
decimals: (config as any)[1] as number, weiPerToken: (config as any)[2] as bigint,
|
||||
enabled: (config as any)[3] as boolean,
|
||||
});
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/* ── READ: Get MAOS discount tiers ── */
|
||||
async getMAOSDiscountTiers(): Promise<MAOSDiscountTier[]> {
|
||||
const client = this.getPublicClient();
|
||||
const result = await client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getMAOSDiscountTiers', args: [],
|
||||
}) as unknown as readonly (readonly [bigint, bigint])[];
|
||||
|
||||
return Array.from(result).map(([minBalance, discountBps]) => ({ minBalance, discountBps: Number(discountBps) }));
|
||||
}
|
||||
|
||||
/* ── READ: Get free tier bytes ── */
|
||||
async getFreeTierBytes(): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'freeTierBytes', args: [],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get base price per MB ── */
|
||||
async getBasePricePerMB(): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'basePricePerMB', args: [],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get contract owner ── */
|
||||
async getOwner(): Promise<Address> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'owner', args: [],
|
||||
}) as Promise<Address>;
|
||||
}
|
||||
|
||||
/* ── READ: Get total revenue for a token ── */
|
||||
async getTotalRevenue(tokenSymbol: string): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'totalRevenue', args: [tokenSymbol],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get total upload count ── */
|
||||
async getTotalUploads(): Promise<bigint> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'totalUploads', args: [],
|
||||
}) as Promise<bigint>;
|
||||
}
|
||||
|
||||
/* ── READ: Get supported token symbols ── */
|
||||
async getTokenSymbols(): Promise<string[]> {
|
||||
const client = this.getPublicClient();
|
||||
return client.readContract({
|
||||
address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'getSupportedTokens', args: [],
|
||||
}) as Promise<string[]>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* ── IPFS Portal Payment Service Instance ── */
|
||||
|
||||
import { PaymentService } from './write-service';
|
||||
export const paymentService = new PaymentService();
|
||||
@@ -0,0 +1,44 @@
|
||||
/* ── IPFS Portal Payment Types ── */
|
||||
|
||||
import type { Address } from 'viem';
|
||||
|
||||
export interface PriceInfo {
|
||||
totalWei: bigint;
|
||||
discountBps: number;
|
||||
finalWei: bigint;
|
||||
isFree: boolean;
|
||||
}
|
||||
|
||||
export interface UserUploadStats {
|
||||
totalBytes: bigint;
|
||||
uploadCount: number;
|
||||
remainingFree: bigint;
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
symbol: string;
|
||||
address: Address;
|
||||
decimals: number;
|
||||
weiPerToken: bigint;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface MAOSDiscountTier {
|
||||
minBalance: bigint;
|
||||
discountBps: number;
|
||||
}
|
||||
|
||||
export interface UploadRecord {
|
||||
cid: string;
|
||||
bytesSize: bigint;
|
||||
tokenSymbol: string;
|
||||
amountPaid: bigint;
|
||||
timestamp: bigint;
|
||||
}
|
||||
|
||||
export interface UserFullStats {
|
||||
totalBytes: bigint;
|
||||
uploadCount: number;
|
||||
remainingFree: bigint;
|
||||
uploads: UploadRecord[];
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/* ── IPFS Portal Payment Write Service ── */
|
||||
|
||||
import {
|
||||
createWalletClient, custom, encodeFunctionData,
|
||||
type Address, type Hash,
|
||||
} from 'viem';
|
||||
import { zkSyncLocal, IPFS_PORTAL_PAYMENT_ABI, ERC20_ABI, DEFAULT_CONTRACT_ADDRESS } from './abi';
|
||||
import type { WalletProvider } from '../wallet';
|
||||
import { PaymentReadService } from './read-service';
|
||||
|
||||
export class PaymentService extends PaymentReadService {
|
||||
private getWalletClient(provider: WalletProvider) {
|
||||
return createWalletClient({ chain: zkSyncLocal, transport: custom(provider) });
|
||||
}
|
||||
|
||||
/* ── WRITE: Pay with ETH ── */
|
||||
async payWithETH(provider: WalletProvider, cid: string, bytesSize: number, priceWei: bigint): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.sendTransaction({
|
||||
account: address, to: this.contractAddress, value: priceWei,
|
||||
data: this.encodePayWithETH(cid, bytesSize),
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Approve ERC20 + Pay with token ── */
|
||||
async approveAndPayWithToken(
|
||||
provider: WalletProvider, tokenAddress: Address,
|
||||
cid: string, bytesSize: number, tokenAmount: bigint, tokenSymbol: string,
|
||||
): Promise<{ approveHash?: Hash; payHash: Hash }> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
const publicClient = this.getPublicClient();
|
||||
|
||||
const allowance = await publicClient.readContract({
|
||||
address: tokenAddress, abi: ERC20_ABI, functionName: 'allowance',
|
||||
args: [address, this.contractAddress],
|
||||
}) as bigint;
|
||||
|
||||
let approveHash: Hash | undefined;
|
||||
if (allowance < tokenAmount) {
|
||||
approveHash = await walletClient.writeContract({
|
||||
account: address, address: tokenAddress, abi: ERC20_ABI,
|
||||
functionName: 'approve', args: [this.contractAddress, tokenAmount],
|
||||
});
|
||||
}
|
||||
|
||||
const payHash = await walletClient.writeContract({
|
||||
account: address, address: this.contractAddress, abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||
functionName: 'payWithToken', args: [cid, BigInt(bytesSize), tokenSymbol],
|
||||
});
|
||||
return { approveHash, payHash };
|
||||
}
|
||||
|
||||
private encodePayWithETH(cid: string, bytesSize: number): `0x${string}` {
|
||||
return encodeFunctionData({
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI, functionName: 'payWithETH', args: [cid, BigInt(bytesSize)],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — set price per MB ── */
|
||||
async adminSetPricePerMB(provider: WalletProvider, priceWei: bigint): Promise<Hash> {
|
||||
return this.execWrite(provider, 'setPricePerMB', [priceWei]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — set free tier bytes ── */
|
||||
async adminSetFreeTierBytes(provider: WalletProvider, bytes: bigint): Promise<Hash> {
|
||||
return this.execWrite(provider, 'setFreeTierBytes', [bytes]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — add/update MAOS discount tier ── */
|
||||
async adminSetDiscountTier(provider: WalletProvider, minBalance: bigint, discountBps: number): Promise<Hash> {
|
||||
return this.execWrite(provider, 'setMAOSDiscountTier', [minBalance, BigInt(discountBps)]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — remove MAOS discount tier ── */
|
||||
async adminRemoveDiscountTier(provider: WalletProvider, index: number): Promise<Hash> {
|
||||
return this.execWrite(provider, 'removeMAOSDiscountTier', [BigInt(index)]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — configure token ── */
|
||||
async adminConfigureToken(
|
||||
provider: WalletProvider, symbol: string, tokenAddress: Address,
|
||||
decimals: number, weiPerToken: bigint, enabled: boolean,
|
||||
): Promise<Hash> {
|
||||
return this.execWrite(provider, 'configureToken', [symbol, tokenAddress, decimals, weiPerToken, enabled]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — withdraw ETH ── */
|
||||
async adminWithdrawETH(provider: WalletProvider, to: Address): Promise<Hash> {
|
||||
return this.execWrite(provider, 'withdrawETH', [to]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — withdraw token ── */
|
||||
async adminWithdrawToken(provider: WalletProvider, tokenSymbol: string, to: Address): Promise<Hash> {
|
||||
return this.execWrite(provider, 'withdrawToken', [tokenSymbol, to]);
|
||||
}
|
||||
|
||||
/* ── WRITE: Admin — transfer ownership ── */
|
||||
async adminTransferOwnership(provider: WalletProvider, newOwner: Address): Promise<Hash> {
|
||||
return this.execWrite(provider, 'transferOwnership', [newOwner]);
|
||||
}
|
||||
|
||||
private async execWrite(
|
||||
provider: WalletProvider,
|
||||
fn: 'payWithETH' | 'payWithToken' | 'setPricePerMB' | 'setFreeTierBytes' | 'setMAOSDiscountTier' | 'removeMAOSDiscountTier' | 'configureToken' | 'setMAOSTokenAddress' | 'withdrawETH' | 'withdrawToken' | 'transferOwnership',
|
||||
args: readonly unknown[],
|
||||
): Promise<Hash> {
|
||||
const walletClient = this.getWalletClient(provider);
|
||||
const [address] = await walletClient.getAddresses();
|
||||
return walletClient.writeContract({
|
||||
account: address, address: this.contractAddress,
|
||||
abi: IPFS_PORTAL_PAYMENT_ABI, functionName: fn, args: args as any,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Check if contract is deployed ── */
|
||||
async isDeployed(): Promise<boolean> {
|
||||
if (this.contractAddress === DEFAULT_CONTRACT_ADDRESS) return false;
|
||||
try {
|
||||
const client = this.getPublicClient();
|
||||
const code = await client.getBytecode({ address: this.contractAddress });
|
||||
return code !== undefined && code !== '0x';
|
||||
} catch { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/* ── Search Index — Client-side full-text search voor IPFS ──
|
||||
*
|
||||
* Bewaart een miniatuur search index in localStorage.
|
||||
* Periodiek te updaten door gepinde files te crawlen en te indexeren.
|
||||
*
|
||||
* Beperking: alleen tekstuele content (.txt, .md, .json, .html)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ Types ════════════════════════════ */
|
||||
|
||||
export interface IndexedEntry {
|
||||
cid: string;
|
||||
name: string;
|
||||
type: 'file' | 'dir';
|
||||
text: string; // geëxtraheerde tekst (max 2048 chars)
|
||||
size: number;
|
||||
indexedAt: number; // timestamp
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
entry: IndexedEntry;
|
||||
score: number; // relevance (higher = better)
|
||||
matchField: 'name' | 'text' | 'cid';
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Storage ════════════════════════════ */
|
||||
|
||||
const STORAGE_KEY = 'ipfs-search-index';
|
||||
|
||||
function loadIndex(): IndexedEntry[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveIndex(entries: IndexedEntry[]) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
} catch {
|
||||
// localStorage vol of blocked — silently fail
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Tekst extractie ════════════════════════════ */
|
||||
|
||||
const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.json', '.html', '.htm', '.csv', '.log', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg']);
|
||||
|
||||
export function isTextFile(name: string): boolean {
|
||||
const ext = name.toLowerCase().slice(name.lastIndexOf('.'));
|
||||
return TEXT_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
export function extractText(raw: string): string {
|
||||
return raw
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML tags
|
||||
.replace(/\s+/g, ' ') // collapse whitespace
|
||||
.replace(/[{}\[\]]/g, ' ') // strip JSON braces
|
||||
.trim()
|
||||
.slice(0, 2048);
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Index beheer ════════════════════════════ */
|
||||
|
||||
export function addToIndex(entry: IndexedEntry) {
|
||||
const index = loadIndex();
|
||||
// Remove oud voor deze CID
|
||||
const filtered = index.filter((e) => e.cid !== entry.cid);
|
||||
filtered.push(entry);
|
||||
saveIndex(filtered);
|
||||
}
|
||||
|
||||
export function removeFromIndex(cid: string) {
|
||||
const index = loadIndex().filter((e) => e.cid !== cid);
|
||||
saveIndex(index);
|
||||
}
|
||||
|
||||
export function clearIndex() {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function getIndexStats() {
|
||||
const index = loadIndex();
|
||||
const totalSize = index.reduce((sum, e) => sum + e.text.length, 0);
|
||||
return {
|
||||
entries: index.length,
|
||||
totalChars: totalSize,
|
||||
lastIndexed: index.length > 0 ? Math.max(...index.map((e) => e.indexedAt)) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Zoeken ════════════════════════════ */
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'de', 'het', 'een', 'van', 'en', 'in', 'is', 'te', 'met', 'op',
|
||||
'voor', 'aan', 'dat', 'die', 'door', 'bij', 'ook', 'maar', 'uit',
|
||||
'naar', 'om', 'al', 'als', 'nog', 'dan', 'of', 'er', 'niet', 'dit',
|
||||
'the', 'a', 'an', 'and', 'or', 'in', 'on', 'at', 'to', 'for',
|
||||
'of', 'is', 'it', 'be', 'by', 'as', 'are', 'was', 'were', 'been',
|
||||
]);
|
||||
|
||||
function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((t) => t.length > 1 && !STOP_WORDS.has(t));
|
||||
}
|
||||
|
||||
function scoreEntry(queryTokens: string[], entry: IndexedEntry): number {
|
||||
let score = 0;
|
||||
const nameLower = entry.name.toLowerCase();
|
||||
const textLower = entry.text.toLowerCase();
|
||||
const cidLower = entry.cid.toLowerCase();
|
||||
|
||||
for (const token of queryTokens) {
|
||||
// Exact match in name = hoogste score
|
||||
if (nameLower.includes(token)) {
|
||||
score += token.length * 10;
|
||||
if (nameLower === token) score += 20; // exact match bonus
|
||||
}
|
||||
// Match in text
|
||||
if (textLower.includes(token)) {
|
||||
score += token.length * 3;
|
||||
}
|
||||
// Match in CID (prefix)
|
||||
if (cidLower.startsWith(token)) {
|
||||
score += token.length * 8;
|
||||
}
|
||||
// Frequentie in text
|
||||
const count = (textLower.match(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
||||
score += count;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function searchIndex(query: string, maxResults = 20): SearchResult[] {
|
||||
if (!query || query.trim().length < 2) return [];
|
||||
|
||||
const queryTokens = tokenize(query);
|
||||
if (queryTokens.length === 0) return [];
|
||||
|
||||
const index = loadIndex();
|
||||
const scored: SearchResult[] = [];
|
||||
|
||||
for (const entry of index) {
|
||||
const score = scoreEntry(queryTokens, entry);
|
||||
if (score > 0) {
|
||||
// Bepaal het beste match veld
|
||||
const nameLower = entry.name.toLowerCase();
|
||||
const cidLower = entry.cid.toLowerCase();
|
||||
let matchField: 'name' | 'text' | 'cid' = 'text';
|
||||
if (queryTokens.some((t) => nameLower.includes(t))) matchField = 'name';
|
||||
else if (queryTokens.some((t) => cidLower.startsWith(t))) matchField = 'cid';
|
||||
|
||||
scored.push({ entry, score, matchField });
|
||||
}
|
||||
}
|
||||
|
||||
return scored.sort((a, b) => b.score - a.score).slice(0, maxResults);
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Crawler ════════════════════════════ */
|
||||
|
||||
export async function rebuildIndex(
|
||||
cids: { cid: string; name: string }[],
|
||||
fetchContent: (cid: string) => Promise<string>,
|
||||
onProgress?: (done: number, total: number) => void,
|
||||
): Promise<{ indexed: number; failed: number }> {
|
||||
let indexed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Filter op text files
|
||||
const textFiles = cids.filter((c) => isTextFile(c.name));
|
||||
|
||||
for (let i = 0; i < textFiles.length; i++) {
|
||||
const file = textFiles[i];
|
||||
try {
|
||||
const raw = await fetchContent(file.cid);
|
||||
const text = extractText(raw);
|
||||
if (text.length > 0) {
|
||||
addToIndex({
|
||||
cid: file.cid,
|
||||
name: file.name,
|
||||
type: 'file',
|
||||
text,
|
||||
size: raw.length,
|
||||
indexedAt: Date.now(),
|
||||
});
|
||||
indexed++;
|
||||
}
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
onProgress?.(i + 1, textFiles.length);
|
||||
}
|
||||
|
||||
return { indexed, failed };
|
||||
}
|
||||
@@ -96,3 +96,13 @@ export function clearHistory(): void {
|
||||
localStorage.removeItem(HISTORY_KEY);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function removeMultipleFromHistory(ids: { cid: string; date: string }[]): UploadRecord[] {
|
||||
const history = getHistory();
|
||||
const idSet = new Set(ids.map((id) => id.cid + '::' + id.date));
|
||||
const filtered = history.filter((h) => !idSet.has(h.cid + '::' + h.date));
|
||||
try {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(filtered));
|
||||
} catch { /* ignore */ }
|
||||
return filtered;
|
||||
}
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/* ── SWR Data Fetching Hooks ──
|
||||
*
|
||||
* Gelijktijdige data-fetching met auto-refresh, caching en revalidation.
|
||||
* Vervangt handmatige useEffect + useState + setInterval patronen.
|
||||
*
|
||||
* Alles fetched via de Next.js API proxy (krijgt cookies/sessie mee).
|
||||
*/
|
||||
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import type { IPFSPin, RepoStats, BWStats, IPFSNodeInfo, IPFSUser } from './api';
|
||||
|
||||
/* ════════════════════════════ Fetcher ════════════════════════════ */
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
async function fetcher<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Config ════════════════════════════ */
|
||||
|
||||
const DEFAULT_REFRESH = 15_000; // 15 seconden
|
||||
|
||||
/* ════════════════════════════ Pins ════════════════════════════ */
|
||||
|
||||
export function usePins(refreshInterval = DEFAULT_REFRESH) {
|
||||
return useSWR<IPFSPin[]>('/api/pins', fetcher, {
|
||||
refreshInterval,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Peers ════════════════════════════ */
|
||||
|
||||
export interface Peer {
|
||||
id: string;
|
||||
addr: string;
|
||||
latency: string;
|
||||
}
|
||||
|
||||
export function usePeers(refreshInterval = DEFAULT_REFRESH) {
|
||||
return useSWR<Peer[]>('/api/peers', async (path) => {
|
||||
const raw = await fetcher<{ Peers?: { Peer: string; Addr: string; Latency: string }[] }>(path);
|
||||
return (raw.Peers || []).map((p) => ({
|
||||
id: p.Peer,
|
||||
addr: p.Addr,
|
||||
latency: p.Latency || '—',
|
||||
}));
|
||||
}, {
|
||||
refreshInterval,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Node Info ════════════════════════════ */
|
||||
|
||||
export function useNodeInfo(refreshInterval?: number) {
|
||||
return useSWR<IPFSNodeInfo>('/api/node/info', fetcher, {
|
||||
refreshInterval,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Repo Stats ════════════════════════════ */
|
||||
|
||||
export function useRepoStats(refreshInterval = DEFAULT_REFRESH) {
|
||||
return useSWR<RepoStats>('/api/repo/stats', async (path) => {
|
||||
const raw = await fetcher<{
|
||||
RepoSize?: number; StorageMax?: number; NumObjects?: number;
|
||||
RepoPath?: string; Version?: string;
|
||||
}>(path);
|
||||
return {
|
||||
repoSize: raw.RepoSize ?? 0,
|
||||
storageMax: raw.StorageMax ?? 0,
|
||||
numObjects: raw.NumObjects ?? 0,
|
||||
repoPath: raw.RepoPath ?? '',
|
||||
version: raw.Version ?? '',
|
||||
};
|
||||
}, {
|
||||
refreshInterval,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Bandwidth ════════════════════════════ */
|
||||
|
||||
export function useBandwidth(refreshInterval = DEFAULT_REFRESH) {
|
||||
return useSWR<BWStats>('/api/bw/stats', async (path) => {
|
||||
const raw = await fetcher<{
|
||||
TotalIn?: number; TotalOut?: number; RateIn?: number; RateOut?: number;
|
||||
}>(path);
|
||||
return {
|
||||
totalIn: raw.TotalIn ?? 0,
|
||||
totalOut: raw.TotalOut ?? 0,
|
||||
rateIn: raw.RateIn ?? 0,
|
||||
rateOut: raw.RateOut ?? 0,
|
||||
};
|
||||
}, {
|
||||
refreshInterval,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Dashboard (alles-in-1) ════════════════════════════ */
|
||||
|
||||
export interface DashboardData {
|
||||
peers: Peer[];
|
||||
pins: IPFSPin[];
|
||||
repo: RepoStats | null;
|
||||
bw: BWStats | null;
|
||||
health: { status: string; node: string } | null;
|
||||
}
|
||||
|
||||
export function useDashboard(refreshInterval = DEFAULT_REFRESH) {
|
||||
return useSWR<DashboardData>('/api/health', async (healthPath) => {
|
||||
const [health, peers, pins, repo, bw] = await Promise.all([
|
||||
fetcher<{ status: string; node: string }>(healthPath).catch(() => null as any),
|
||||
fetcher<{ Peers?: { Peer: string; Addr: string; Latency: string }[] }>('/api/peers')
|
||||
.then((raw) => (raw.Peers || []).map((p) => ({ id: p.Peer, addr: p.Addr, latency: p.Latency || '—' })))
|
||||
.catch(() => [] as Peer[]),
|
||||
fetcher<{ Keys?: Record<string, { Type: string }> }>('/api/pins')
|
||||
.then((raw) => raw.Keys ? Object.entries(raw.Keys).map(([cid]) => ({ cid, name: '', size: 0, created: '' })) : [])
|
||||
.catch(() => [] as IPFSPin[]),
|
||||
fetcher<{ RepoSize?: number; StorageMax?: number; NumObjects?: number }>('/api/repo/stats')
|
||||
.then((raw) => ({ repoSize: raw.RepoSize ?? 0, storageMax: raw.StorageMax ?? 0, numObjects: raw.NumObjects ?? 0, repoPath: '', version: '' }))
|
||||
.catch(() => null),
|
||||
fetcher<{ TotalIn?: number; TotalOut?: number }>('/api/bw/stats')
|
||||
.then((raw) => ({ totalIn: raw.TotalIn ?? 0, totalOut: raw.TotalOut ?? 0, rateIn: 0, rateOut: 0 }))
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
return { peers, pins, repo, bw, health };
|
||||
}, {
|
||||
refreshInterval,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Daily Stats (paginated) ════════════════════════════ */
|
||||
|
||||
export interface DailyStat {
|
||||
date: string;
|
||||
uploads: number;
|
||||
bytesAdded: number;
|
||||
bytesRemoved: number;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
export function useDailyStats() {
|
||||
return useSWRInfinite<DailyStat[]>(
|
||||
(pageIndex, previousData) => {
|
||||
if (previousData && previousData.length < PAGE_SIZE) return null;
|
||||
return `/api/stats/daily?page=${pageIndex}&limit=${PAGE_SIZE}`;
|
||||
},
|
||||
fetcher,
|
||||
{ revalidateFirstPage: false },
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Health ════════════════════════════ */
|
||||
|
||||
export function useHealth() {
|
||||
return useSWR<{ status: string; node: string }>('/api/health', fetcher, {
|
||||
refreshInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Users (admin) ════════════════════════════ */
|
||||
|
||||
export function useUsers() {
|
||||
return useSWR<IPFSUser[]>('/api/users', async (path) => {
|
||||
const raw = await fetcher<IPFSUser[] | { users: IPFSUser[] }>(path);
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (raw && Array.isArray(raw.users)) return raw.users;
|
||||
return [];
|
||||
}, {
|
||||
refreshInterval: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ Theme ════════════════════════════
|
||||
* Live dark/light mode toggle.
|
||||
* Slaat voorkeur op in localStorage, past direct CSS class op <html>.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
import { getSettings, saveSettings } from '@/lib/storage';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
interface ThemeCtx {
|
||||
theme: Theme;
|
||||
toggle: () => void;
|
||||
setTheme: (t: Theme) => void;
|
||||
}
|
||||
|
||||
/* ── Context ── */
|
||||
|
||||
const ThemeContext = createContext<ThemeCtx | null>(null);
|
||||
|
||||
/* ── Provider ── */
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>('dark');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Init — run once on mount
|
||||
useEffect(() => {
|
||||
const saved = getSettings().theme;
|
||||
if (saved === 'light' || saved === 'dark') {
|
||||
setThemeState(saved);
|
||||
}
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Sync class + localStorage on every change
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
document.documentElement.classList.toggle('light', theme === 'light');
|
||||
saveSettings({ theme });
|
||||
}, [theme, mounted]);
|
||||
|
||||
const toggle = () => {
|
||||
// Enable smooth transitions during theme switch
|
||||
document.documentElement.classList.add('theme-transitioning');
|
||||
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
// Remove transition class after animation completes
|
||||
setTimeout(() => document.documentElement.classList.remove('theme-transitioning'), 400);
|
||||
};
|
||||
|
||||
const setTheme = (t: Theme) => setThemeState(t);
|
||||
|
||||
// Render children immediately (no flash because .dark is default in CSS)
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggle, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Hook ── */
|
||||
|
||||
export function useTheme(): ThemeCtx {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme moet binnen ThemeProvider worden gebruikt');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/* ── useRealtime — SSE live data hook ──
|
||||
*
|
||||
* Consumeert Server-Sent Events van /api/events.
|
||||
* Geeft live bandwidth, peer count, repo stats.
|
||||
* Auto-reconnect bij verbroken verbinding.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/* ════════════════════════════ Types ════════════════════════════ */
|
||||
|
||||
export interface LiveBW {
|
||||
totalIn: number;
|
||||
totalOut: number;
|
||||
rateIn: number;
|
||||
rateOut: number;
|
||||
deltaIn: number;
|
||||
deltaOut: number;
|
||||
}
|
||||
|
||||
export interface LivePeers {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface LiveRepo {
|
||||
repoSize: number;
|
||||
storageMax: number;
|
||||
numObjects: number;
|
||||
}
|
||||
|
||||
export interface RealtimeState {
|
||||
connected: boolean;
|
||||
bandwidth: LiveBW | null;
|
||||
peers: LivePeers | null;
|
||||
repo: LiveRepo | null;
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Hook ════════════════════════════ */
|
||||
|
||||
export function useRealtime(): RealtimeState {
|
||||
const [state, setState] = useState<RealtimeState>({
|
||||
connected: false,
|
||||
bandwidth: null,
|
||||
peers: null,
|
||||
repo: null,
|
||||
});
|
||||
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
function connect() {
|
||||
if (cancelled) return;
|
||||
|
||||
const es = new EventSource('/api/events');
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.addEventListener('connected', () => {
|
||||
if (!cancelled) setState((prev) => ({ ...prev, connected: true }));
|
||||
});
|
||||
|
||||
es.addEventListener('bandwidth', (e: MessageEvent) => {
|
||||
if (!cancelled) {
|
||||
const bw: LiveBW = JSON.parse(e.data);
|
||||
setState((prev) => ({ ...prev, bandwidth: bw }));
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('peers', (e: MessageEvent) => {
|
||||
if (!cancelled) {
|
||||
const peers: LivePeers = JSON.parse(e.data);
|
||||
setState((prev) => ({ ...prev, peers }));
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('repo', (e: MessageEvent) => {
|
||||
if (!cancelled) {
|
||||
const repo: LiveRepo = JSON.parse(e.data);
|
||||
setState((prev) => ({ ...prev, repo }));
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
if (!cancelled) {
|
||||
setState((prev) => ({ ...prev, connected: false }));
|
||||
// Auto-reconnect na 3s
|
||||
reconnectTimeoutRef.current = setTimeout(connect, 3_000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
+15
-9
@@ -1,5 +1,20 @@
|
||||
/* ── Wallet connection (EIP-6963 + MAOS extension) ── */
|
||||
|
||||
/* ── Global type augmentation for window properties ── */
|
||||
declare global {
|
||||
interface Window {
|
||||
ethereum?: {
|
||||
isMetaMask?: boolean;
|
||||
isRabby?: boolean;
|
||||
isMAOSv6?: boolean;
|
||||
request: (args: { method: string; params?: unknown[] }) => Promise<any>;
|
||||
on?: (event: string, cb: (...args: any[]) => void) => void;
|
||||
removeListener?: (event: string, cb: (...args: any[]) => void) => void;
|
||||
};
|
||||
maosv6?: WalletProvider;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Types ── */
|
||||
export interface WalletProvider {
|
||||
request: (args: { method: string; params?: unknown[] }) => Promise<any>;
|
||||
@@ -69,12 +84,9 @@ export function discoverWallets(): Promise<DetectedWallet[]> {
|
||||
|
||||
// Also check legacy window.ethereum
|
||||
setTimeout(() => {
|
||||
// @ts-expect-error - window.ethereum
|
||||
const legacy = window.ethereum as WalletProvider | undefined;
|
||||
if (legacy && !wallets.some(w => w.provider === legacy)) {
|
||||
// @ts-expect-error
|
||||
const isMetaMask = window.ethereum?.isMetaMask === true;
|
||||
// @ts-expect-error - isMAOSv6
|
||||
const isMAOS = window.maosv6 !== undefined || window.ethereum?.isMAOSv6 === true;
|
||||
wallets.push({
|
||||
name: isMAOS ? 'MAOS Wallet' : isMetaMask ? 'MetaMask' : 'Wallet',
|
||||
@@ -91,9 +103,7 @@ export function discoverWallets(): Promise<DetectedWallet[]> {
|
||||
/* ── Get best provider (MAOS preferred if available) ── */
|
||||
export function getInjectedProvider(): WalletProvider | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
// @ts-expect-error - window.maosv6
|
||||
if (window.maosv6) return window.maosv6 as WalletProvider;
|
||||
// @ts-expect-error - window.ethereum
|
||||
if (window.ethereum) return window.ethereum as WalletProvider;
|
||||
return null;
|
||||
}
|
||||
@@ -101,13 +111,9 @@ export function getInjectedProvider(): WalletProvider | null {
|
||||
/* ── Detect wallet type ── */
|
||||
export function detectWalletType(): 'maos' | 'metamask' | 'rabby' | 'other' | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
// @ts-expect-error
|
||||
if (window.maosv6) return 'maos';
|
||||
// @ts-expect-error
|
||||
if (window.ethereum?.isRabby) return 'rabby';
|
||||
// @ts-expect-error
|
||||
if (window.ethereum?.isMetaMask) return 'metamask';
|
||||
// @ts-expect-error
|
||||
if (window.ethereum) return 'other';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/* ── IPFS Portal Middleware ──
|
||||
*
|
||||
* Server-side route guard.
|
||||
* Beschermt private pages op basis van JWT session cookie (wallet-based auth).
|
||||
* Verifieert JWT lokaal — geen external call naar Python backend.
|
||||
*/
|
||||
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
|
||||
|
||||
/* ════════════════════════════ Route rules ════════════════════════════ */
|
||||
|
||||
interface RouteGuard {
|
||||
pattern: RegExp;
|
||||
requireAuth: boolean;
|
||||
requireAdmin: boolean;
|
||||
}
|
||||
|
||||
const guards: RouteGuard[] = [
|
||||
// Admin pages — require admin role
|
||||
{ pattern: /^\/admin(\/|$)/, requireAuth: true, requireAdmin: true },
|
||||
// Dashboard — requires any authenticated user
|
||||
{ pattern: /^\/dashboard(\/|$)/, requireAuth: true, requireAdmin: false },
|
||||
// Profile — requires any authenticated user
|
||||
{ pattern: /^\/profile(\/|$)/, requireAuth: true, requireAdmin: false },
|
||||
// IPNS — requires any authenticated user
|
||||
{ pattern: /^\/ipns(\/|$)/, requireAuth: true, requireAdmin: false },
|
||||
// Pins — requires any authenticated user
|
||||
{ pattern: /^\/pins(\/|$)/, requireAuth: true, requireAdmin: false },
|
||||
// Upload — requires any authenticated user
|
||||
{ pattern: /^\/upload(\/|$)/, requireAuth: true, requireAdmin: false },
|
||||
// Usage — requires any authenticated user
|
||||
{ pattern: /^\/usage(\/|$)/, requireAuth: true, requireAdmin: false },
|
||||
// Settings — requires any authenticated user
|
||||
{ pattern: /^\/settings(\/|$)/, requireAuth: true, requireAdmin: false },
|
||||
// Users list — requires any auth (admin check op page zelf)
|
||||
{ pattern: /^\/users(\/|$)/, requireAuth: true, requireAdmin: false },
|
||||
];
|
||||
|
||||
/* ════════════════════════════ Handler ════════════════════════════ */
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// Check if path matches any guard
|
||||
const guard = guards.find((g) => g.pattern.test(pathname));
|
||||
if (!guard) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Read session cookie
|
||||
const token = req.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (!token) {
|
||||
const loginUrl = new URL('/login', req.url);
|
||||
loginUrl.searchParams.set('redirect', pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// Verify JWT (local, no external call)
|
||||
const session = await verifySessionJWT(token);
|
||||
if (!session) {
|
||||
const loginUrl = new URL('/login', req.url);
|
||||
loginUrl.searchParams.set('redirect', pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
if (guard.requireAdmin && session.role !== 'admin') {
|
||||
return NextResponse.redirect(new URL('/dashboard', req.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Config ────────────
|
||||
* Alleen matchen op specifieke paden — niet op _next static files of API routes.
|
||||
*/
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/admin/:path*',
|
||||
'/dashboard/:path*',
|
||||
'/ipns/:path*',
|
||||
'/profile/:path*',
|
||||
'/pins/:path*',
|
||||
'/upload/:path*',
|
||||
'/usage/:path*',
|
||||
'/settings/:path*',
|
||||
'/users/:path*',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
exclude: ['node_modules'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user