feat: initial IPFS Portal — Next.js frontend for remote IPFS node management

Multi-file upload with Quick Upload + Crypto Payment tabs.
Dashboard with live IPFS metrics, Explorer, Peers, Pins, History, Settings pages.
Docker deploy (standalone output), full test suite (16 Playwright tests).
Payment integration via IPFSPortalPayment + MockUSDC contracts on zkSync.
This commit is contained in:
maikrolf
2026-06-25 19:47:57 +02:00
commit 1ddc89479c
42 changed files with 8383 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
'use client';
import { useEffect, useState } from 'react';
import { listUsers, createUser, deleteUser } from '@/lib/api';
import PortalLayout from '@/app/layout-portal';
import { paymentService, type UserFullStats, type UploadRecord } from '@/lib/payment';
import { formatWeiToETH, formatBytes } from '@/lib/wallet';
import {
Users, Plus, Trash2, UserPlus, Search, Wallet,
HardDrive, Upload, Clock, ExternalLink, Copy,
CheckCircle, Loader2, XCircle,
} from 'lucide-react';
export default function UsersPage() {
// ── htpasswd users ──
const [users, setUsers] = useState<{ username: string; created: string; active: boolean }[]>([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [newUser, setNewUser] = useState('');
const [newPass, setNewPass] = useState('');
// ── Wallet lookup ──
const [walletAddr, setWalletAddr] = useState('');
const [walletStats, setWalletStats] = useState<UserFullStats | null>(null);
const [walletLoading, setWalletLoading] = useState(false);
const [walletError, setWalletError] = useState<string | null>(null);
const [copiedCid, setCopiedCid] = useState<string | null>(null);
async function load() {
try {
const items = await listUsers();
setUsers(items);
} catch { /* ignore */ }
finally { setLoading(false); }
}
useEffect(() => { load(); }, []);
async function handleCreate() {
if (!newUser || !newPass) return;
try {
await createUser(newUser, newPass);
setNewUser('');
setNewPass('');
setShowAdd(false);
await load();
} catch { /* ignore */ }
}
async function handleDelete(username: string) {
try {
await deleteUser(username);
setUsers((u) => u.filter((x) => x.username !== username));
} catch { /* ignore */ }
}
// ── Wallet lookup ──
async function lookupWallet() {
const addr = walletAddr.trim();
if (!addr || !addr.startsWith('0x') || addr.length !== 42) {
setWalletError('Invalid address (must be 0x... 42 chars)');
return;
}
setWalletLoading(true);
setWalletError(null);
setWalletStats(null);
try {
const stats = await paymentService.getUserUploads(addr as `0x${string}`);
setWalletStats(stats);
} catch (e: any) {
setWalletError(e.message || 'Failed to fetch wallet stats');
} finally {
setWalletLoading(false);
}
}
async function copyCid(cid: string) {
try {
await navigator.clipboard.writeText(cid);
setCopiedCid(cid);
setTimeout(() => setCopiedCid(null), 2000);
} catch { /* ignore */ }
}
function formatTimestamp(ts: bigint): string {
const d = new Date(Number(ts) * 1000);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
}
return (
<PortalLayout>
{/* ── Header with count badge ── */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Users</h1>
<p className="text-sm text-surface-400 mt-1">{users.length} IPFS users</p>
</div>
<button
onClick={() => setShowAdd(!showAdd)}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-brand-600 text-white text-sm font-medium hover:bg-brand-500 transition-colors"
>
<UserPlus className="w-4 h-4" />
Add User
</button>
</div>
{/* ── Add user form ── */}
{showAdd && (
<div className="glass rounded-xl p-4 mb-6 animate-fade-in">
<div className="flex flex-col sm:flex-row gap-3">
<input
value={newUser} onChange={(e) => setNewUser(e.target.value)}
placeholder="Username"
className="flex-1 px-3 py-2 rounded-lg bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500"
/>
<input
value={newPass} onChange={(e) => setNewPass(e.target.value)}
type="password" placeholder="Password"
className="flex-1 px-3 py-2 rounded-lg bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500"
/>
<button onClick={handleCreate} disabled={!newUser || !newPass}
className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-500 transition-colors disabled:opacity-50">
Create
</button>
</div>
</div>
)}
{/* ── Two-column layout ── */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* Left: htpasswd user list */}
<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>
) : users.length === 0 ? (
<div className="p-8 text-center text-sm text-surface-500">No users configured</div>
) : (
<div className="divide-y divide-surface-800">
{users.map((u) => (
<div key={u.username} className="flex items-center justify-between px-5 py-3 hover:bg-surface-800/30 transition-colors">
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-lg bg-accent-purple/10">
<Users className="w-3.5 h-3.5 text-accent-purple" />
</div>
<div>
<div className="text-sm text-surface-200">{u.username}</div>
<div className="text-xs text-surface-500 mt-0.5">Created {u.created}</div>
</div>
</div>
<div className="flex items-center gap-3">
{u.active && <span className="status-dot status-dot--online" />}
<button onClick={() => handleDelete(u.username)}
className="p-1.5 rounded-lg hover:bg-accent-rose/10 transition-colors">
<Trash2 className="w-3.5 h-3.5 text-surface-500 hover:text-accent-rose" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Right: Wallet lookup */}
<div className="lg:col-span-2 space-y-4">
<div className="glass rounded-xl p-4 space-y-3">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Wallet className="w-4 h-4 text-brand-400" /> Wallet Lookup
</h3>
<p className="text-[10px] text-surface-500">Enter a wallet address to see on-chain upload stats</p>
<div className="flex gap-2">
<input value={walletAddr} onChange={e => setWalletAddr(e.target.value)}
onKeyDown={e => e.key === 'Enter' && lookupWallet()}
placeholder="0x..." className="flex-1 px-3 py-1.5 rounded-lg bg-surface-900 border border-surface-700 text-xs font-mono text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500"
/>
<button onClick={lookupWallet} disabled={walletLoading}
className="px-3 py-1.5 rounded-lg bg-brand-600 hover:bg-brand-500 disabled:opacity-50 text-white text-xs transition-colors">
{walletLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</button>
</div>
{walletError && (
<div className="flex items-center gap-1.5 text-xs text-accent-rose">
<XCircle className="w-3 h-3 shrink-0" /> {walletError}
</div>
)}
</div>
{/* ── Wallet stats ── */}
{walletStats && (
<>
{/* Stats cards */}
<div className="grid grid-cols-2 gap-3">
<div className="glass rounded-xl p-3">
<div className="flex items-center gap-1.5 text-[10px] text-surface-500 mb-1">
<HardDrive className="w-3 h-3" /> Total Bytes
</div>
<div className="text-lg font-bold text-white">{formatBytes(walletStats.totalBytes)}</div>
</div>
<div className="glass rounded-xl p-3">
<div className="flex items-center gap-1.5 text-[10px] text-surface-500 mb-1">
<Upload className="w-3 h-3" /> Uploads
</div>
<div className="text-lg font-bold text-white">{walletStats.uploadCount}</div>
</div>
<div className="glass rounded-xl p-3">
<div className="flex items-center gap-1.5 text-[10px] text-surface-500 mb-1">
<Clock className="w-3 h-3" /> Free Remaining
</div>
<div className="text-lg font-bold text-accent-cyan">{formatBytes(walletStats.remainingFree)}</div>
</div>
<div className="glass rounded-xl p-3">
<div className="flex items-center gap-1.5 text-[10px] text-surface-500 mb-1">
<Wallet className="w-3 h-3" /> Address
</div>
<div className="text-xs font-mono text-surface-300 break-all">
{walletAddr.slice(0, 6)}...{walletAddr.slice(-4)}
</div>
</div>
</div>
{/* ── Upload history ── */}
{walletStats.uploads.length > 0 && (
<div className="glass rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-surface-800">
<h4 className="text-xs font-semibold text-surface-300">Upload History ({walletStats.uploads.length})</h4>
</div>
<div className="divide-y divide-surface-800 max-h-80 overflow-y-auto">
{[...walletStats.uploads].reverse().map((rec, i) => (
<div key={i} className="px-4 py-2.5 space-y-1 hover:bg-surface-800/30 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs">
<span className={`px-1 py-0.5 rounded text-[10px] font-medium ${
rec.tokenSymbol === 'FREE' ? 'bg-accent-green/10 text-accent-green' :
rec.tokenSymbol === 'ETH' ? 'bg-brand-500/10 text-brand-400' :
'bg-accent-cyan/10 text-accent-cyan'
}`}>{rec.tokenSymbol}</span>
<span className="text-surface-400">{formatBytes(rec.bytesSize)}</span>
</div>
<div className="text-[10px] text-surface-500">{formatTimestamp(rec.timestamp)}</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<span className="text-[10px] font-mono text-surface-500 truncate max-w-[120px]">{rec.cid}</span>
<button onClick={() => copyCid(rec.cid)}
className="p-0.5 rounded hover:bg-surface-700 transition-colors">
{copiedCid === rec.cid
? <CheckCircle className="w-3 h-3 text-accent-green" />
: <Copy className="w-3 h-3 text-surface-500" />}
</button>
</div>
{rec.amountPaid > BigInt(0) && (
<span className="text-[10px] text-surface-400">
{rec.tokenSymbol === 'ETH' ? formatWeiToETH(rec.amountPaid, 6) : rec.amountPaid.toString()} {rec.tokenSymbol}
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
{walletStats.uploads.length === 0 && (
<div className="glass rounded-xl p-4 text-center">
<p className="text-xs text-surface-500">No uploads found for this address</p>
</div>
)}
</>
)}
</div>
</div>
</PortalLayout>
);
}