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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user