forked from maik/IPFS-portal
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,8 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.next
|
||||||
|
out
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Test outputs
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# ── IPFS Portal — Multi-stage Docker Build ──
|
||||||
|
|
||||||
|
# ---- Build ----
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV DOCKER_BUILD=true
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- Production ----
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# Copy standalone output from builder
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
ENV PORT=3445
|
||||||
|
EXPOSE 3445
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ── IPFS Portal Deploy Script ──
|
||||||
|
# Usage: ./deploy.sh [remote_host] [remote_port]
|
||||||
|
# remote_host: SSH target (default: maik@192.168.1.176)
|
||||||
|
# remote_port: SSH port (default: 22)
|
||||||
|
#
|
||||||
|
# Builds Docker image locally, pushes to Docker Hub or saves as tar,
|
||||||
|
# then deploys to remote server via SSH.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - Docker installed locally and on remote
|
||||||
|
# - SSH key-based auth to remote
|
||||||
|
# - .env.production on remote or pass via env vars
|
||||||
|
#
|
||||||
|
# Server requirements:
|
||||||
|
# - Docker + docker compose plugin
|
||||||
|
# - Port 3444 available
|
||||||
|
# - nginx reverse proxy (optional, for TLS)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REMOTE_HOST="${1:-maik@192.168.1.176}"
|
||||||
|
REMOTE_PORT="${2:-22}"
|
||||||
|
IMAGE_NAME="maos/ipfs-portal"
|
||||||
|
TAG="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
REMOTE_DIR="/opt/ipfs-portal"
|
||||||
|
|
||||||
|
echo "═══ IPFS Portal Deploy ═══"
|
||||||
|
echo " Target: ${REMOTE_HOST}"
|
||||||
|
echo " Image: ${IMAGE_NAME}:${TAG}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Step 1: Build Docker image ──
|
||||||
|
echo "▸ Building Docker image..."
|
||||||
|
docker build \
|
||||||
|
--build-arg NEXT_PUBLIC_APP_URL="https://maos.dedyn.io" \
|
||||||
|
-t "${IMAGE_NAME}:${TAG}" \
|
||||||
|
-t "${IMAGE_NAME}:latest" \
|
||||||
|
-f Dockerfile \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo " ✓ Image built: ${IMAGE_NAME}:${TAG}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Step 2: Save + compress image ──
|
||||||
|
echo "▸ Saving image..."
|
||||||
|
docker save "${IMAGE_NAME}:${TAG}" | gzip > /tmp/ipfs-portal.tar.gz
|
||||||
|
echo " ✓ Saved to /tmp/ipfs-portal.tar.gz ($(du -h /tmp/ipfs-portal.tar.gz | cut -f1))"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Step 3: Copy to remote ──
|
||||||
|
echo "▸ Copying to ${REMOTE_HOST}:${REMOTE_DIR}/..."
|
||||||
|
ssh -p "${REMOTE_PORT}" "${REMOTE_HOST}" "mkdir -p ${REMOTE_DIR}"
|
||||||
|
scp -P "${REMOTE_PORT}" /tmp/ipfs-portal.tar.gz "${REMOTE_HOST}:${REMOTE_DIR}/image.tar.gz"
|
||||||
|
rm /tmp/ipfs-portal.tar.gz
|
||||||
|
echo " ✓ Image copied"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Step 4: Deploy on remote ──
|
||||||
|
echo "▸ Deploying on remote..."
|
||||||
|
ssh -p "${REMOTE_PORT}" "${REMOTE_HOST}" << REMOTESHELL
|
||||||
|
set -euo pipefail
|
||||||
|
cd ${REMOTE_DIR}
|
||||||
|
|
||||||
|
# Load new image
|
||||||
|
docker image rm -f ${IMAGE_NAME}:latest 2>/dev/null || true
|
||||||
|
gunzip -c image.tar.gz | docker load
|
||||||
|
rm image.tar.gz
|
||||||
|
|
||||||
|
# Create docker-compose.yml if not exists
|
||||||
|
if [ ! -f docker-compose.yml ]; then
|
||||||
|
cat > docker-compose.yml << 'COMPOSE'
|
||||||
|
services:
|
||||||
|
ipfs-portal:
|
||||||
|
image: maos/ipfs-portal:latest
|
||||||
|
container_name: ipfs-portal
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3445:3445"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3445
|
||||||
|
- NEXT_TELEMETRY_DISABLED=1
|
||||||
|
- USER_API_BASE=http://192.168.1.176:8444
|
||||||
|
- USER_API_ADMIN_KEY=maos-admin-2024
|
||||||
|
- KUBO_API_URL=http://tom1687.no-ip.org:8443
|
||||||
|
- KUBO_BASIC_AUTH=portal:ipfs-portal-2024
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3445/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
COMPOSE
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restart container
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
sleep 3
|
||||||
|
if docker ps --filter "name=ipfs-portal" --format "{{.Status}}" | grep -q "Up"; then
|
||||||
|
echo " ✓ Container is running"
|
||||||
|
curl -s http://localhost:3445/api/health
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo " ✗ Container failed to start"
|
||||||
|
docker logs ipfs-portal --tail 30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
REMOTESHELL
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "═══ Deploy complete ═══"
|
||||||
|
echo " URL: https://maos.dedyn.io (via nginx) or http://192.168.1.176:3445"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
images: { unoptimized: true },
|
||||||
|
output: process.env.DOCKER_BUILD ? 'standalone' : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+1913
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@maos/ipfs-portal",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "IPFS Portal — Next.js frontend for remote IPFS node management",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --port 3445",
|
||||||
|
"build": "next build",
|
||||||
|
"export": "next build && npx serve out"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"next": "^16.2.7",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"viem": "^2.29.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : 2,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3445',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PORT = 3444;
|
||||||
|
const ROOT = path.join(__dirname, 'out');
|
||||||
|
const MIME = { '.html':'text/html','.js':'text/javascript','.css':'text/css','.png':'image/png','.svg':'image/svg+xml','.ico':'image/x-icon','.json':'application/json' };
|
||||||
|
|
||||||
|
http.createServer((req, res) => {
|
||||||
|
let fp = path.join(ROOT, req.url === '/' ? 'index.html' : req.url);
|
||||||
|
// SPA fallback: serve index.html for non-file routes
|
||||||
|
if (!fs.existsSync(fp) || fs.statSync(fp).isDirectory()) {
|
||||||
|
fp = path.join(ROOT, 'index.html');
|
||||||
|
}
|
||||||
|
if (fs.existsSync(fp)) {
|
||||||
|
const ext = path.extname(fp);
|
||||||
|
res.writeHead(200, { 'Content-Type': MIME[ext] || 'text/html' });
|
||||||
|
fs.createReadStream(fp).pipe(res);
|
||||||
|
} else {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not found');
|
||||||
|
}
|
||||||
|
}).listen(PORT, () => {
|
||||||
|
console.log(IPFS Portal: http://localhost:);
|
||||||
|
console.log(Network: http://100.112.2.113:);
|
||||||
|
});
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import PortalLayout from '@/app/layout-portal';
|
||||||
|
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,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
type ViewMode = 'overview' | 'pricing' | 'tokens' | 'tiers';
|
||||||
|
type TxStatus = { ok: boolean; msg: string } | null;
|
||||||
|
|
||||||
|
export default function AdminPaymentPage() {
|
||||||
|
const [address, setAddress] = useState<string | null>(null);
|
||||||
|
const [provider, setProvider] = useState<WalletProvider | null>(null);
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [wrongChain, setWrongChain] = useState(false);
|
||||||
|
const [walletType, setWalletType] = useState<string | null>(null);
|
||||||
|
const [view, setView] = useState<ViewMode>('overview');
|
||||||
|
const [txStatus, setTxStatus] = useState<TxStatus>(null);
|
||||||
|
|
||||||
|
// Contract state
|
||||||
|
const [owner, setOwner] = useState<string | null>(null);
|
||||||
|
const [deployed, setDeployed] = useState(false);
|
||||||
|
const [basePrice, setBasePrice] = useState<bigint>(BigInt(0));
|
||||||
|
const [freeTier, setFreeTier] = useState<bigint>(BigInt(0));
|
||||||
|
const [tokens, setTokens] = useState<TokenInfo[]>([]);
|
||||||
|
const [tiers, setTiers] = useState<MAOSDiscountTier[]>([]);
|
||||||
|
const [totalUploads, setTotalUploads] = useState<bigint>(BigInt(0));
|
||||||
|
const [revenues, setRevenues] = useState<Record<string, bigint>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [contractBalance, setContractBalance] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
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);
|
||||||
|
const [own, price, free, t, tiersData, uploads] = await Promise.all([
|
||||||
|
paymentService.getOwner(),
|
||||||
|
paymentService.getBasePricePerMB(),
|
||||||
|
paymentService.getFreeTierBytes(),
|
||||||
|
paymentService.getSupportedTokens(),
|
||||||
|
paymentService.getMAOSDiscountTiers(),
|
||||||
|
paymentService.getTotalUploads(),
|
||||||
|
]);
|
||||||
|
setOwner(own);
|
||||||
|
setBasePrice(price);
|
||||||
|
setFreeTier(free);
|
||||||
|
setTiers(tiersData);
|
||||||
|
|
||||||
|
// Revenue per token
|
||||||
|
// Deduplicate by symbol (contract has a bug where USDC appears twice)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const uniqueTokens = t.filter(tk => {
|
||||||
|
if (seen.has(tk.symbol)) return false;
|
||||||
|
seen.add(tk.symbol);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
setTokens(uniqueTokens);
|
||||||
|
|
||||||
|
const symbols = uniqueTokens.map(tk => tk.symbol);
|
||||||
|
const revEntries = await Promise.all(
|
||||||
|
symbols.map(s => paymentService.getTotalRevenue(s))
|
||||||
|
);
|
||||||
|
const revMap: Record<string, bigint> = {};
|
||||||
|
symbols.forEach((s, i) => { revMap[s] = revEntries[i]; });
|
||||||
|
setRevenues(revMap);
|
||||||
|
|
||||||
|
// Balances via RPC
|
||||||
|
const balMap: Record<string, string> = {};
|
||||||
|
for (const tk of t) {
|
||||||
|
try {
|
||||||
|
const client = paymentService['getPublicClient']();
|
||||||
|
if (tk.symbol === 'ETH') {
|
||||||
|
const bal = await client.getBalance({ address: paymentService['contractAddress'] });
|
||||||
|
balMap['ETH'] = bal.toString();
|
||||||
|
} else if (tk.address && tk.address !== '0x0000000000000000000000000000000000000000') {
|
||||||
|
const ERC20_BAL_ABI = [{ type: 'function', name: 'balanceOf', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }], stateMutability: 'view' }] as const;
|
||||||
|
const bal = await client.readContract({
|
||||||
|
address: tk.address as `0x${string}`,
|
||||||
|
abi: ERC20_BAL_ABI,
|
||||||
|
functionName: 'balanceOf',
|
||||||
|
args: [paymentService['contractAddress']],
|
||||||
|
});
|
||||||
|
balMap[tk.symbol] = (bal as bigint).toString();
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
setContractBalance(balMap);
|
||||||
|
setTotalUploads(uploads);
|
||||||
|
setTokens(t);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load contract state:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
// ── Connect wallet ──
|
||||||
|
async function handleConnect() {
|
||||||
|
setConnecting(true);
|
||||||
|
setTxStatus(null);
|
||||||
|
try {
|
||||||
|
const result = await connectWallet();
|
||||||
|
const prov = getInjectedProvider();
|
||||||
|
setAddress(result.address);
|
||||||
|
setProvider(prov);
|
||||||
|
if (result.chainId !== 270) {
|
||||||
|
setWrongChain(true);
|
||||||
|
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) {
|
||||||
|
setTxStatus({ ok: false, msg: e.message || 'Connect failed' });
|
||||||
|
} finally {
|
||||||
|
setConnecting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin action helper ──
|
||||||
|
async function doTx(label: string, fn: () => Promise<string>) {
|
||||||
|
if (!provider) return;
|
||||||
|
setTxStatus(null);
|
||||||
|
try {
|
||||||
|
const hash = await fn();
|
||||||
|
setTxStatus({ ok: true, msg: `${label} success: ${hash.slice(0, 10)}...` });
|
||||||
|
await refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setTxStatus({ ok: false, msg: `${label} failed: ${e.message || e}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form state ──
|
||||||
|
const [priceInput, setPriceInput] = useState('');
|
||||||
|
const [freeInput, setFreeInput] = useState('');
|
||||||
|
const [tierBalance, setTierBalance] = useState('');
|
||||||
|
const [tierBps, setTierBps] = useState('');
|
||||||
|
const [tierRemoveIdx, setTierRemoveIdx] = useState<number>(-1);
|
||||||
|
const [tokenSymbol, setTokenSymbol] = useState('');
|
||||||
|
const [tokenAddr, setTokenAddr] = useState('');
|
||||||
|
const [tokenDec, setTokenDec] = useState('18');
|
||||||
|
const [tokenWei, setTokenWei] = useState('');
|
||||||
|
const [tokenEnabled, setTokenEnabled] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalLayout>
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Payment Admin</h1>
|
||||||
|
<p className="text-sm text-surface-400 mt-1">Manage IPFS Portal payment contract</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{address && (
|
||||||
|
<span className="text-xs text-surface-400 flex items-center gap-1.5">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${isOwner ? 'bg-accent-green' : 'bg-surface-500'}`} />
|
||||||
|
{isOwner ? 'Owner' : 'Viewer'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={refresh} disabled={loading} className="p-2 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-400 transition-colors">
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Connect wallet ── */}
|
||||||
|
{!address && (
|
||||||
|
<div className="glass rounded-xl p-6 text-center space-y-4">
|
||||||
|
<Wallet className="w-8 h-8 text-surface-500 mx-auto" />
|
||||||
|
<p className="text-sm text-surface-400">Connect wallet to view admin panel</p>
|
||||||
|
<button onClick={handleConnect} disabled={connecting}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{connecting ? <><Loader2 className="w-4 h-4 animate-spin" /> Connecting...</> : <><Wallet className="w-4 h-4" /> Connect Wallet</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{wrongChain && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 text-sm mb-4">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
|
Please switch to zkSync Local (chain 270)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{address && (
|
||||||
|
<>
|
||||||
|
{/* ── Nav tabs ── */}
|
||||||
|
<div className="flex gap-1 mb-6 p-1 rounded-lg bg-surface-800/50 w-fit">
|
||||||
|
{(['overview', 'pricing', 'tokens', 'tiers'] as ViewMode[]).map(v => (
|
||||||
|
<button key={v} onClick={() => setView(v)}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-medium capitalize transition-colors ${
|
||||||
|
view === v ? 'bg-surface-700 text-white' : 'text-surface-400 hover:text-surface-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v === 'overview' ? 'Overview' : v === 'pricing' ? 'Pricing' : v === 'tokens' ? 'Tokens' : 'Discount Tiers'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Loading ── */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2 text-surface-400 text-sm py-12 justify-center">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading contract state...
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Pricing ── */}
|
||||||
|
{view === 'pricing' && (
|
||||||
|
<div className="max-w-2xl space-y-4">
|
||||||
|
<div className="glass rounded-xl p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<Settings className="w-4 h-4 text-brand-400" /> Pricing Config
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Current values */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<div className="px-3 py-2 rounded bg-surface-800/30">
|
||||||
|
<div className="text-surface-400">Base Price / MB</div>
|
||||||
|
<div className="text-surface-200 font-medium mt-0.5">{formatWeiToETH(basePrice, 8)} ETH</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 rounded bg-surface-800/30">
|
||||||
|
<div className="text-surface-400">Free Tier</div>
|
||||||
|
<div className="text-surface-200 font-medium mt-0.5">{formatBytes(freeTier)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<>
|
||||||
|
{/* Set price */}
|
||||||
|
<div className="space-y-2 pt-3 border-t border-surface-800">
|
||||||
|
<label className="text-xs text-surface-400">Set Base Price / MB (wei)</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input value={priceInput} onChange={e => setPriceInput(e.target.value)}
|
||||||
|
placeholder={basePrice.toString()}
|
||||||
|
className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
<button onClick={() => {
|
||||||
|
if (!priceInput) return;
|
||||||
|
const wei = BigInt(priceInput);
|
||||||
|
doTx('Set price', () => paymentService.adminSetPricePerMB(provider!, wei));
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors">
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-surface-500">
|
||||||
|
Current: {formatWeiToETH(basePrice, 8)} ETH — e.g. 100000000000000 = 0.0001 ETH
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Set free tier */}
|
||||||
|
<div className="space-y-2 pt-3 border-t border-surface-800">
|
||||||
|
<label className="text-xs text-surface-400">Set Free Tier (bytes)</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input value={freeInput} onChange={e => setFreeInput(e.target.value)}
|
||||||
|
placeholder={freeTier.toString()}
|
||||||
|
className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
<button onClick={() => {
|
||||||
|
if (!freeInput) return;
|
||||||
|
doTx('Set free tier', () => paymentService.adminSetFreeTierBytes(provider!, BigInt(freeInput)));
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors">
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-surface-500">
|
||||||
|
Current: {formatBytes(freeTier)} — 10485760 = 10 MB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Withdraw */}
|
||||||
|
<div className="pt-3 border-t border-surface-800 space-y-2">
|
||||||
|
<label className="text-xs text-surface-400">Withdraw Funds</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tokens.filter(t => t.enabled).map(tk => {
|
||||||
|
const bal = contractBalance[tk.symbol];
|
||||||
|
if (!bal || bal === '0') return null;
|
||||||
|
return (
|
||||||
|
<button key={tk.symbol} onClick={() =>
|
||||||
|
doTx(`Withdraw ${tk.symbol}`, () =>
|
||||||
|
tk.symbol === 'ETH'
|
||||||
|
? paymentService.adminWithdrawETH(provider!, address as `0x${string}`)
|
||||||
|
: paymentService.adminWithdrawToken(provider!, tk.symbol, address as `0x${string}`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 text-amber-400 text-xs transition-colors">
|
||||||
|
Withdraw {tk.symbol} ({tk.symbol === 'ETH' ? formatWeiToETH(bal, 4) : bal})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Tokens ── */}
|
||||||
|
{view === 'tokens' && (
|
||||||
|
<div className="max-w-2xl space-y-4">
|
||||||
|
<div className="glass rounded-xl p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<Coins className="w-4 h-4 text-brand-400" /> Supported Tokens
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Token list */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tokens.map(tk => (
|
||||||
|
<div key={tk.symbol} className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/40 text-xs">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${tk.enabled ? 'bg-accent-green' : 'bg-surface-500'}`} />
|
||||||
|
<span className="font-medium text-surface-200">{tk.symbol}</span>
|
||||||
|
<span className="font-mono text-surface-500">{tk.address.slice(0, 10)}...</span>
|
||||||
|
<span className="text-surface-500">{tk.decimals} decimals</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-surface-400">{formatWeiToETH(tk.weiPerToken, 4)} wei/token</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin: configure token */}
|
||||||
|
{isOwner && (
|
||||||
|
<div className="glass rounded-xl p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Configure Token</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<input value={tokenSymbol} onChange={e => setTokenSymbol(e.target.value.toUpperCase())}
|
||||||
|
placeholder="Symbol (e.g. USDC)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
<input value={tokenAddr} onChange={e => setTokenAddr(e.target.value)}
|
||||||
|
placeholder="Token address (0x...)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
<input value={tokenDec} onChange={e => setTokenDec(e.target.value)}
|
||||||
|
placeholder="Decimals (e.g. 6)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
<input value={tokenWei} onChange={e => setTokenWei(e.target.value)}
|
||||||
|
placeholder="weiPerToken (e.g. 500000000000000)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-surface-400">
|
||||||
|
<input type="checkbox" checked={tokenEnabled} onChange={e => setTokenEnabled(e.target.checked)}
|
||||||
|
className="rounded border-surface-600"
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<button onClick={() => {
|
||||||
|
if (!tokenSymbol || !tokenAddr) return;
|
||||||
|
doTx('Configure token', () => paymentService.adminConfigureToken(
|
||||||
|
provider!, tokenSymbol, tokenAddr as `0x${string}`, parseInt(tokenDec), BigInt(tokenWei || '0'), tokenEnabled
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white font-medium transition-colors">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Withdraw section */}
|
||||||
|
{isOwner && Object.keys(contractBalance).length > 0 && (
|
||||||
|
<div className="glass rounded-xl p-5 space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<PiggyBank className="w-4 h-4 text-brand-400" /> Withdraw
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(contractBalance).filter(([_, bal]) => bal !== '0').map(([sym, bal]) => (
|
||||||
|
<button key={sym} onClick={() =>
|
||||||
|
doTx(`Withdraw ${sym}`, () =>
|
||||||
|
sym === 'ETH'
|
||||||
|
? paymentService.adminWithdrawETH(provider!, address as `0x${string}`)
|
||||||
|
: paymentService.adminWithdrawToken(provider!, sym, address as `0x${string}`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 text-amber-400 text-xs transition-colors">
|
||||||
|
Withdraw {sym} ({sym === 'ETH' ? formatWeiToETH(bal, 4) : bal})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{Object.values(contractBalance).every(b => b === '0') && (
|
||||||
|
<span className="text-xs text-surface-500">No balance to withdraw</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Discount Tiers ── */}
|
||||||
|
{view === 'tiers' && (
|
||||||
|
<div className="max-w-2xl space-y-4">
|
||||||
|
<div className="glass rounded-xl p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<Tags className="w-4 h-4 text-brand-400" /> MAOS Discount Tiers
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Tier list */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tiers.map((tier, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/40 text-xs">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-surface-200 font-medium">{formatWeiToETH(tier.minBalance, 0)} MAOS</span>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-accent-cyan/10 text-accent-cyan">{tier.discountBps / 100}% off</span>
|
||||||
|
</div>
|
||||||
|
{isOwner && (
|
||||||
|
<button onClick={() => doTx('Remove tier', () => paymentService.adminRemoveDiscountTier(provider!, i))}
|
||||||
|
className="p-1 rounded hover:bg-accent-rose/10 text-surface-400 hover:text-accent-rose transition-colors">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin: add tier */}
|
||||||
|
{isOwner && (
|
||||||
|
<div className="glass rounded-xl p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<Plus className="w-4 h-4 text-brand-400" /> Add / Update Tier
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<label className="text-[10px] text-surface-500">Min MAOS balance</label>
|
||||||
|
<input value={tierBalance} onChange={e => setTierBalance(e.target.value)}
|
||||||
|
placeholder="e.g. 5000" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<label className="text-[10px] text-surface-500">Discount %</label>
|
||||||
|
<input value={tierBps} onChange={e => setTierBps(e.target.value)}
|
||||||
|
placeholder="e.g. 50" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => {
|
||||||
|
if (!tierBalance || !tierBps) return;
|
||||||
|
const bal = BigInt(parseFloat(tierBalance) * 1e18);
|
||||||
|
const bps = parseInt(tierBps) * 100;
|
||||||
|
doTx('Set tier', () => paymentService.adminSetDiscountTier(provider!, bal, bps));
|
||||||
|
}}
|
||||||
|
className="px-4 py-1.5 rounded-lg bg-brand-500 hover:bg-brand-400 text-white text-xs font-medium transition-colors whitespace-nowrap">
|
||||||
|
<Plus className="w-3.5 h-3.5 inline mr-1" /> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── TX Status ── */}
|
||||||
|
{txStatus && (
|
||||||
|
<div className={`fixed bottom-6 right-6 flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg text-sm animate-fade-in ${
|
||||||
|
txStatus.ok
|
||||||
|
? 'bg-accent-green/10 border border-accent-green/20 text-accent-green'
|
||||||
|
: 'bg-accent-rose/10 border border-accent-rose/20 text-accent-rose'
|
||||||
|
}`}>
|
||||||
|
{txStatus.ok ? <CheckCircle className="w-4 h-4 shrink-0" /> : <XCircle className="w-4 h-4 shrink-0" />}
|
||||||
|
{txStatus.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PortalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
/* ── IPFS Portal API Proxy ──
|
||||||
|
*
|
||||||
|
* Catch-all /api/* handler.
|
||||||
|
* Routes incoming calls to the appropriate backend:
|
||||||
|
* /api/users* → Python User Management API (port 8444)
|
||||||
|
* /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
|
||||||
|
*
|
||||||
|
* In dev (localhost), this file proxies to the remote server.
|
||||||
|
* In production, nginx handles the backend routing directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/* ── Backend targets ── */
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
/* ── Route matching ── */
|
||||||
|
|
||||||
|
interface RouteMatch {
|
||||||
|
target: 'users' | 'ipfs' | 'health';
|
||||||
|
path: string; // path relative to the backend
|
||||||
|
method: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchRoute(path: string[], method: string): RouteMatch | null {
|
||||||
|
if (!path || path.length === 0) return null;
|
||||||
|
|
||||||
|
const [segment, ...rest] = path;
|
||||||
|
|
||||||
|
switch (segment) {
|
||||||
|
case 'health':
|
||||||
|
return { target: 'health', path: '/health', method };
|
||||||
|
|
||||||
|
case 'users':
|
||||||
|
return { target: 'users', 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:
|
||||||
|
return { target: 'ipfs', path: '/' + path.join('/'), method };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── User API proxy ── */
|
||||||
|
|
||||||
|
async function proxyUserAPI(path: string, method: string, body: ReadableStream<Uint8Array> | null): Promise<Response> {
|
||||||
|
const url = `${USER_API_BASE}${path}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'X-Admin-Key': ADMIN_KEY,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (method === 'POST' || method === 'PUT') {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── IPFS Kubo proxy (via nginx) ── */
|
||||||
|
|
||||||
|
async function proxyIPFS(path: string, method: string, req: NextRequest): Promise<Response> {
|
||||||
|
// In dev, the Kubo API is behind nginx with Basic Auth.
|
||||||
|
// If no KUBO_API_URL is configured, return a clear error.
|
||||||
|
const kuboSvc = process.env.KUBO_API_URL || process.env.NEXT_PUBLIC_KUBO_API_URL;
|
||||||
|
if (!kuboSvc) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'IPFS proxy not configured', detail: 'Set KUBO_API_URL in env for dev, or use nginx routing in production' },
|
||||||
|
{ status: 501 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map /api/explorer/ls/<cid> → /api/v0/ls?arg=<cid>, etc.
|
||||||
|
const url = new URL(kuboSvc);
|
||||||
|
const kuboPath = mapToKuboAPI(path, method);
|
||||||
|
// Split pathname and query string — url.pathname encodes `?` as `%3F`
|
||||||
|
const [namePart, ...qsParts] = kuboPath.split('?');
|
||||||
|
url.pathname = namePart;
|
||||||
|
if (qsParts.length > 0) {
|
||||||
|
url.search = qsParts.join('?');
|
||||||
|
} else {
|
||||||
|
// Forward incoming query params (used by ipns/publish etc.)
|
||||||
|
const incomingSearch = req.nextUrl.search;
|
||||||
|
if (incomingSearch) {
|
||||||
|
url.search = incomingSearch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const auth = process.env.KUBO_BASIC_AUTH;
|
||||||
|
if (auth) {
|
||||||
|
headers['Authorization'] = 'Basic ' + Buffer.from(auth).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward Content-Type (preserves multipart boundary for file uploads)
|
||||||
|
const reqCt = req.headers.get('content-type');
|
||||||
|
if (reqCt) {
|
||||||
|
headers['Content-Type'] = reqCt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kubo uses POST for everything
|
||||||
|
const fetchOpts: RequestInit & { duplex?: string } = {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
};
|
||||||
|
// Node.js fetch requires duplex: 'half' when streaming a body
|
||||||
|
if (method === 'POST' && req.body) {
|
||||||
|
fetchOpts.duplex = 'half';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward body for add/pin operations
|
||||||
|
if (method === 'POST' && req.body) {
|
||||||
|
fetchOpts.body = req.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url.toString(), fetchOpts);
|
||||||
|
} catch (fetchErr: any) {
|
||||||
|
console.error('[proxyIPFS] fetch error:', fetchErr.message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'IPFS proxy fetch failed', detail: fetchErr.message?.substring(0, 200) },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: res.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': res.headers.get('Content-Type') || 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapToKuboAPI(path: string, method: string): string {
|
||||||
|
// Normalize: /api/node/info → /api/v0/id, etc.
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
// Explorer routes: /explorer/ls/<cid>, /explorer/cat/<cid>, etc.
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
|
// IPNS routes
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
|
// Dashboard routes
|
||||||
|
if (p === 'repo/stats') return '/api/v0/repo/stat';
|
||||||
|
if (p === 'bw/stats') return '/api/v0/bw/stats';
|
||||||
|
return '/api/v0/' + p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main handler ── */
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
const { path = [] } = await params;
|
||||||
|
const route = matchRoute(path, 'GET');
|
||||||
|
if (!route) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
|
||||||
|
switch (route.target) {
|
||||||
|
case 'health':
|
||||||
|
return handleHealth(req);
|
||||||
|
case 'users':
|
||||||
|
return proxyResult(await proxyUserAPI(route.path, 'GET', null));
|
||||||
|
case 'ipfs':
|
||||||
|
return proxyIPFS(route.path, 'GET', req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
const { path = [] } = await params;
|
||||||
|
const route = matchRoute(path, '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 'ipfs':
|
||||||
|
return proxyIPFS(route.path, 'POST', req);
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: 'Method not allowed' }, { status: 405 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
const { path = [] } = await params;
|
||||||
|
const route = matchRoute(path, '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 'ipfs':
|
||||||
|
return proxyIPFS(route.path, 'DELETE', req);
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: 'Method not allowed' }, { status: 405 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Health check ── */
|
||||||
|
|
||||||
|
async function handleHealth(req: NextRequest): Promise<NextResponse> {
|
||||||
|
// Check User API reachability
|
||||||
|
let userApiOk = false;
|
||||||
|
let userApiMsg = 'unknown';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${USER_API_BASE}/users`, {
|
||||||
|
headers: { 'X-Admin-Key': ADMIN_KEY },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
userApiOk = true;
|
||||||
|
userApiMsg = 'connected';
|
||||||
|
} else {
|
||||||
|
userApiMsg = `status ${r.status}`;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
userApiMsg = e.message?.substring(0, 60) || 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Kubo API reachability
|
||||||
|
let kuboApiOk = false;
|
||||||
|
let kuboApiMsg = 'not configured';
|
||||||
|
const kuboSvc = process.env.KUBO_API_URL;
|
||||||
|
const kuboAuth = process.env.KUBO_BASIC_AUTH;
|
||||||
|
if (kuboSvc) {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (kuboAuth) {
|
||||||
|
headers['Authorization'] = 'Basic ' + Buffer.from(kuboAuth).toString('base64');
|
||||||
|
}
|
||||||
|
const r = await fetch(kuboSvc.replace(/\/+$/, '') + '/api/v0/version', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
const data = await r.json();
|
||||||
|
kuboApiOk = true;
|
||||||
|
kuboApiMsg = `v${data.Version || 'unknown'}`;
|
||||||
|
} else {
|
||||||
|
kuboApiMsg = `status ${r.status}`;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
kuboApiMsg = e.message?.substring(0, 60) || 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overall = userApiOk || kuboApiOk ? 'ok' : 'degraded';
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: overall,
|
||||||
|
userApi: userApiMsg,
|
||||||
|
kuboApi: kuboApiMsg,
|
||||||
|
mode: process.env.KUBO_API_URL ? 'proxy' : 'minimal',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Helpers ── */
|
||||||
|
|
||||||
|
async function proxyResult(res: Response): Promise<NextResponse> {
|
||||||
|
const text = await res.text();
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: res.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/* ── Payment Verification API ──
|
||||||
|
*
|
||||||
|
* Verifieert of een IPFS upload betaald is via het smart contract.
|
||||||
|
* Read-only — vereist geen wallet.
|
||||||
|
*
|
||||||
|
* GET /api/payment/verify/upload?address=0x...&cid=Qm...
|
||||||
|
* → Check of een specifieke upload in de user upload history zit
|
||||||
|
*
|
||||||
|
* GET /api/payment/verify/user?address=0x...
|
||||||
|
* → Haal alle upload stats op voor een adres
|
||||||
|
*
|
||||||
|
* POST /api/payment/verify/tx
|
||||||
|
* → Verifieer of een tx hash een geldige payment is
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createPublicClient, http, type Address, type Hash, type Chain } from 'viem';
|
||||||
|
import { IPFS_PORTAL_PAYMENT_ABI } from '@/lib/payment';
|
||||||
|
|
||||||
|
const RPC_URL = process.env.ZKSYNC_RPC_URL || 'http://tom1687.no-ip.org:3050';
|
||||||
|
const CONTRACT_ADDRESS = (process.env.PAYMENT_CONTRACT_ADDRESS || '0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe') as Address;
|
||||||
|
|
||||||
|
const zkSyncLocal: Chain = {
|
||||||
|
id: 270,
|
||||||
|
name: 'zkSync Local',
|
||||||
|
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||||
|
rpcUrls: { default: { http: [RPC_URL] } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = createPublicClient({
|
||||||
|
chain: zkSyncLocal,
|
||||||
|
transport: http(RPC_URL),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/* ── GET /api/payment/verify?address=0x...&cid=Qm... ── */
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const address = searchParams.get('address') as Address | null;
|
||||||
|
const cid = searchParams.get('cid');
|
||||||
|
|
||||||
|
if (!address) {
|
||||||
|
return NextResponse.json({ error: 'Missing address parameter' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if contract exists
|
||||||
|
const bytecode = await client.getBytecode({ address: CONTRACT_ADDRESS });
|
||||||
|
if (!bytecode || bytecode === '0x') {
|
||||||
|
return NextResponse.json({
|
||||||
|
deployed: false,
|
||||||
|
error: 'Payment contract not deployed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all user stats (viem returns tuple — convert to typed object)
|
||||||
|
const stats = await client.readContract({
|
||||||
|
address: CONTRACT_ADDRESS,
|
||||||
|
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||||
|
functionName: 'getUserStats',
|
||||||
|
args: [address],
|
||||||
|
}) as unknown as [bigint, bigint, bigint, Array<[string, bigint, string, bigint, bigint]>];
|
||||||
|
|
||||||
|
const [totalBytes, uploadCount, remainingFree, rawUploads] = stats;
|
||||||
|
const uploads = rawUploads.map((u: [string, bigint, string, bigint, bigint]) => ({
|
||||||
|
cid: u[0],
|
||||||
|
bytesSize: u[1],
|
||||||
|
tokenSymbol: u[2],
|
||||||
|
amountPaid: u[3],
|
||||||
|
timestamp: u[4],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// If cid specified, find matching upload
|
||||||
|
const matchingUpload = cid
|
||||||
|
? uploads.find(u => u.cid === cid) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Get price info
|
||||||
|
let pricing = null;
|
||||||
|
if (uploadCount > BigInt(0) && uploads.length > 0) {
|
||||||
|
const lastUpload = uploads[uploads.length - 1];
|
||||||
|
try {
|
||||||
|
const [totalWei, discountBps, finalWei, usedFree] = await client.readContract({
|
||||||
|
address: CONTRACT_ADDRESS,
|
||||||
|
abi: IPFS_PORTAL_PAYMENT_ABI,
|
||||||
|
functionName: 'getPrice',
|
||||||
|
args: [lastUpload.bytesSize, address],
|
||||||
|
}) as unknown as [bigint, bigint, bigint, boolean];
|
||||||
|
pricing = {
|
||||||
|
totalWei: totalWei.toString(),
|
||||||
|
discountBps: Number(discountBps),
|
||||||
|
finalWei: finalWei.toString(),
|
||||||
|
usedFree,
|
||||||
|
};
|
||||||
|
} catch { /* ignore read error */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
deployed: true,
|
||||||
|
address,
|
||||||
|
contractAddress: CONTRACT_ADDRESS,
|
||||||
|
stats: {
|
||||||
|
totalBytes: totalBytes.toString(),
|
||||||
|
uploadCount: uploadCount.toString(),
|
||||||
|
remainingFree: remainingFree.toString(),
|
||||||
|
},
|
||||||
|
pricing,
|
||||||
|
uploads: uploads.map((u) => ({
|
||||||
|
cid: u.cid,
|
||||||
|
bytesSize: u.bytesSize.toString(),
|
||||||
|
tokenSymbol: u.tokenSymbol,
|
||||||
|
amountPaid: u.amountPaid.toString(),
|
||||||
|
timestamp: Number(u.timestamp),
|
||||||
|
matched: matchingUpload?.cid === u.cid,
|
||||||
|
})),
|
||||||
|
verified: matchingUpload !== null,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[payment/verify] Error:', err.message);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Verification failed',
|
||||||
|
detail: err.message?.substring(0, 200),
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── POST /api/payment/verify/tx — verify a transaction receipt ── */
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { txHash } = body as { txHash?: string };
|
||||||
|
|
||||||
|
if (!txHash || typeof txHash !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing txHash' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get transaction receipt
|
||||||
|
const receipt = await client.getTransactionReceipt({ hash: txHash as Hash });
|
||||||
|
if (!receipt) {
|
||||||
|
return NextResponse.json({ confirmed: false, error: 'Transaction not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's from our contract
|
||||||
|
const isOurContract = receipt.to?.toLowerCase() === CONTRACT_ADDRESS.toLowerCase();
|
||||||
|
|
||||||
|
// Get transaction details
|
||||||
|
const tx = await client.getTransaction({ hash: txHash as Hash });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
confirmed: receipt.status === 'success',
|
||||||
|
blockNumber: Number(receipt.blockNumber),
|
||||||
|
from: receipt.from,
|
||||||
|
to: receipt.to,
|
||||||
|
isOurContract,
|
||||||
|
value: tx?.value?.toString() || '0',
|
||||||
|
gasUsed: receipt.gasUsed?.toString(),
|
||||||
|
effectiveGasPrice: receipt.effectiveGasPrice?.toString(),
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[payment/verify/tx] Error:', err.message);
|
||||||
|
return NextResponse.json({
|
||||||
|
confirmed: false,
|
||||||
|
error: 'Verification failed',
|
||||||
|
detail: err.message?.substring(0, 200),
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
'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 {
|
||||||
|
HardDrive,
|
||||||
|
Globe,
|
||||||
|
Wifi,
|
||||||
|
Database,
|
||||||
|
Server,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownLeft,
|
||||||
|
BarChart3,
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
const iv = setInterval(load, 15000);
|
||||||
|
return () => clearInterval(iv);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const repoSizeGb = repo ? (repo.repoSize / 1e9).toFixed(2) : '—';
|
||||||
|
const bwIn = bw ? (bw.totalIn / 1e6).toFixed(1) : '—';
|
||||||
|
const bwOut = bw ? (bw.totalOut / 1e6).toFixed(1) : '—';
|
||||||
|
|
||||||
|
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: 'Repo', value: `${repoSizeGb} GB`, icon: Database, color: 'text-accent-amber', bg: 'bg-accent-amber/10' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalLayout>
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||||
|
<p className="text-sm text-surface-400 mt-1">IPFS node overview, storage, and bandwidth</p>
|
||||||
|
</div>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-surface-500">
|
||||||
|
<div className="w-3 h-3 rounded-full border border-surface-600 border-t-transparent animate-spin" />
|
||||||
|
refreshing…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
{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}`}>
|
||||||
|
<s.icon className={`w-4 h-4 ${s.color}`} />
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight className="w-3.5 h-3.5 text-surface-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-white">{s.value}</div>
|
||||||
|
<div className="text-xs text-surface-400 mt-1">{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Peer + Bandwidth */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||||
|
{/* Peers list */}
|
||||||
|
<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>
|
||||||
|
</h2>
|
||||||
|
{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">
|
||||||
|
{peers.slice(0, 20).map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center justify-between py-1.5 border-b border-surface-800/50 last:border-0">
|
||||||
|
<div className="truncate max-w-[180px] text-sm text-surface-300 font-mono text-[11px]">
|
||||||
|
{p.id.slice(0, 20)}…
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] text-surface-500">{p.latency}</span>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-accent-green" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 pt-3 border-t border-surface-800">
|
||||||
|
<Link href="/peers" className="text-xs text-brand-400 hover:text-brand-300 transition-colors">
|
||||||
|
View all peers →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bandwidth */}
|
||||||
|
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||||
|
<h2 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-4 h-4 text-accent-amber" />
|
||||||
|
Bandwidth
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<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">{bwIn} 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">Out</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-surface-200">{bwOut} MB</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-surface-500 text-center">Total since node start</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IPNS quick */}
|
||||||
|
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||||
|
<h2 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Fingerprint className="w-4 h-4 text-accent-purple" />
|
||||||
|
IPNS Names
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-surface-400 mb-4">
|
||||||
|
Publish CIDs to human-readable IPNS names, or resolve existing names.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Link
|
||||||
|
href="/ipns"
|
||||||
|
className="flex items-center justify-between px-3 py-2.5 rounded-lg bg-surface-900/50 border border-surface-800 hover:border-surface-700 text-sm text-surface-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<span>Manage IPNS Keys</span>
|
||||||
|
<ArrowUpRight className="w-3.5 h-3.5 text-surface-500" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/explorer"
|
||||||
|
className="flex items-center justify-between px-3 py-2.5 rounded-lg bg-surface-900/50 border border-surface-800 hover:border-surface-700 text-sm text-surface-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<span>Browse Explorer</span>
|
||||||
|
<ArrowUpRight className="w-3.5 h-3.5 text-surface-500" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Pins + Repo details */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Pins */}
|
||||||
|
<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"><HardDrive className="w-4 h-4 text-accent-purple" />Recent Pins</span>
|
||||||
|
<span className="text-xs text-surface-500">{pins.length} total</span>
|
||||||
|
</h2>
|
||||||
|
{pins.length === 0 ? (
|
||||||
|
<p className="text-sm text-surface-500">No pins yet — upload or add content to get started.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 max-h-56 overflow-y-auto">
|
||||||
|
{pins.slice(0, 8).map((pin) => (
|
||||||
|
<div key={pin.cid} className="flex items-center justify-between py-1.5 border-b border-surface-800/50 last:border-0">
|
||||||
|
<div className="truncate max-w-[240px] text-sm text-surface-300 font-mono text-[11px]">
|
||||||
|
{pin.name || pin.cid.slice(0, 24) + '…'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-surface-500">
|
||||||
|
{(pin.size / 1024).toFixed(1)} KB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 pt-3 border-t border-surface-800 flex gap-3">
|
||||||
|
<Link href="/pins" className="text-xs text-brand-400 hover:text-brand-300 transition-colors">
|
||||||
|
Manage pins →
|
||||||
|
</Link>
|
||||||
|
<Link href="/upload" className="text-xs text-brand-400 hover:text-brand-300 transition-colors">
|
||||||
|
Upload new →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Repo Details */}
|
||||||
|
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||||
|
<h2 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Database className="w-4 h-4 text-accent-amber" />
|
||||||
|
Repository
|
||||||
|
</h2>
|
||||||
|
{repo ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between py-2 border-b border-surface-800/50">
|
||||||
|
<span className="text-xs text-surface-400">Size</span>
|
||||||
|
<span className="text-xs text-surface-200 font-mono">{repoSizeGb} GB</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-surface-800/50">
|
||||||
|
<span className="text-xs text-surface-400">Objects</span>
|
||||||
|
<span className="text-xs text-surface-200">{repo.numObjects.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-surface-800/50">
|
||||||
|
<span className="text-xs text-surface-400">Storage Max</span>
|
||||||
|
<span className="text-xs text-surface-200">{(repo.storageMax / 1e9).toFixed(1)} GB</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b border-surface-800/50">
|
||||||
|
<span className="text-xs text-surface-400">Version</span>
|
||||||
|
<span className="text-xs text-surface-200 font-mono">{repo.version || health?.node || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2">
|
||||||
|
<span className="text-xs text-surface-400">Path</span>
|
||||||
|
<span className="text-xs text-surface-500 font-mono truncate max-w-[200px]" title={repo.repoPath}>{repo.repoPath}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-surface-500">Repo stats unavailable (Kubo may not expose /api/v0/repo/stat via gateway).</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PortalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface BreadcrumbSegment {
|
||||||
|
cid: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbsProps {
|
||||||
|
path: BreadcrumbSegment[];
|
||||||
|
onNavigate: (cid: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) {
|
||||||
|
return (
|
||||||
|
<nav className="flex items-center flex-wrap gap-1 text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate(path[0]?.cid ?? '')}
|
||||||
|
className="text-surface-400 hover:text-white transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Root
|
||||||
|
</button>
|
||||||
|
{path.map((segment, i) => (
|
||||||
|
<span key={`${segment.cid}-${i}`} className="flex items-center gap-1">
|
||||||
|
<span className="text-surface-600 text-xs">/</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate(segment.cid)}
|
||||||
|
className={`transition-colors font-mono text-xs ${
|
||||||
|
i === path.length - 1
|
||||||
|
? 'text-white'
|
||||||
|
: 'text-surface-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{segment.name}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Globe } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CIDInputProps {
|
||||||
|
onNavigate: (cid: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CIDInput({ onNavigate }: CIDInputProps) {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
onNavigate(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') handleSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center gap-0 glass rounded-xl overflow-hidden focus-within:ring-2 focus-within:ring-brand-500/50 transition-all duration-200">
|
||||||
|
<div className="flex items-center justify-center pl-4">
|
||||||
|
<Globe className="w-4 h-4 text-surface-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Enter CID or IPNS name..."
|
||||||
|
className="flex-1 px-3 py-3 bg-transparent text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!value.trim()}
|
||||||
|
className="px-5 py-3 bg-brand-600 text-white text-sm font-medium hover:bg-brand-500 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-surface-500 mt-2 ml-1">
|
||||||
|
Paste a CID to browse its contents
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { IPFSEntry } from '@/lib/api';
|
||||||
|
import FileIcon from './FileIcon';
|
||||||
|
import { CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DirectoryListingProps {
|
||||||
|
entries: IPFSEntry[];
|
||||||
|
loading: boolean;
|
||||||
|
onNavigate: (cid: string, name: string) => void;
|
||||||
|
onPreview: (cid: string) => void;
|
||||||
|
pins: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
const s = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0);
|
||||||
|
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">
|
||||||
|
<div className="w-4 h-4 rounded bg-surface-700" />
|
||||||
|
<div className="flex-1 h-3 rounded bg-surface-700" />
|
||||||
|
<div className="w-12 h-3 rounded bg-surface-700 hidden sm:block" />
|
||||||
|
<div className="w-16 h-3 rounded bg-surface-700 hidden md:block" />
|
||||||
|
<div className="w-20 h-3 rounded bg-surface-700 hidden lg:block" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DirectoryListing({
|
||||||
|
entries,
|
||||||
|
loading,
|
||||||
|
onNavigate,
|
||||||
|
onPreview,
|
||||||
|
pins,
|
||||||
|
}: DirectoryListingProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="glass rounded-xl overflow-hidden divide-y divide-surface-800/50">
|
||||||
|
<SkeletonRow />
|
||||||
|
<SkeletonRow />
|
||||||
|
<SkeletonRow />
|
||||||
|
<SkeletonRow />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="glass rounded-xl p-12 flex flex-col items-center justify-center text-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-surface-800 flex items-center justify-center mb-3">
|
||||||
|
<svg className="w-6 h-6 text-surface-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25-2.25M12 11.625v6.75" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-surface-400">This directory is empty</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...entries].sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass rounded-xl overflow-hidden">
|
||||||
|
{/* Header row — hidden on smallest screens */}
|
||||||
|
<div className="hidden sm:flex items-center gap-3 px-5 py-2.5 text-xs font-medium text-surface-500 uppercase tracking-wider border-b border-surface-800/50">
|
||||||
|
<div className="w-4 shrink-0" />
|
||||||
|
<div className="flex-1">Name</div>
|
||||||
|
<div className="w-12 text-center">Type</div>
|
||||||
|
<div className="w-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>
|
||||||
|
|
||||||
|
<div className="divide-y divide-surface-800/50">
|
||||||
|
{sorted.map((entry) => {
|
||||||
|
const isPinned = pins.includes(entry.hash);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.hash}
|
||||||
|
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-800/30 transition-colors cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
entry.type === 'dir'
|
||||||
|
? onNavigate(entry.hash, entry.name)
|
||||||
|
: onPreview(entry.hash)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FileIcon name={entry.name} type={entry.type} className="w-4 h-4 shrink-0" />
|
||||||
|
|
||||||
|
<span className="flex-1 text-sm text-surface-200 truncate min-w-0">
|
||||||
|
{entry.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-xs text-surface-500 w-12 text-center shrink-0 hidden sm:block">
|
||||||
|
{entry.type}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-xs text-surface-500 w-16 text-right shrink-0 hidden md:block">
|
||||||
|
{formatSize(entry.size)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<code className="text-xs text-surface-500 w-20 text-right shrink-0 hidden lg:block font-mono">
|
||||||
|
{truncateHash(entry.hash)}
|
||||||
|
</code>
|
||||||
|
|
||||||
|
<div className="w-10 flex justify-center shrink-0">
|
||||||
|
{isPinned && (
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Code,
|
||||||
|
Code2,
|
||||||
|
Image,
|
||||||
|
File,
|
||||||
|
Folder,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface FileIconProps {
|
||||||
|
name: string;
|
||||||
|
type: 'file' | 'dir';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileIcon({ name, type, className = 'w-4 h-4' }: FileIconProps) {
|
||||||
|
if (type === 'dir') {
|
||||||
|
return <Folder className={`${className} text-accent-cyan`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
switch (ext) {
|
||||||
|
case 'md':
|
||||||
|
return <FileText className={`${className} text-surface-400`} />;
|
||||||
|
case 'json':
|
||||||
|
return <Code className={`${className} text-surface-400`} />;
|
||||||
|
case 'js':
|
||||||
|
case 'ts':
|
||||||
|
case 'py':
|
||||||
|
case 'css':
|
||||||
|
case 'html':
|
||||||
|
return <Code2 className={`${className} text-surface-400`} />;
|
||||||
|
case 'jpg':
|
||||||
|
case 'jpeg':
|
||||||
|
case 'png':
|
||||||
|
case 'gif':
|
||||||
|
case 'webp':
|
||||||
|
case 'svg':
|
||||||
|
return <Image className={`${className} text-surface-400`} />;
|
||||||
|
case 'pdf':
|
||||||
|
return <File className={`${className} text-surface-400`} />;
|
||||||
|
default:
|
||||||
|
return <File className={`${className} text-surface-400`} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { X, Download } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FilePreviewProps {
|
||||||
|
cid: string;
|
||||||
|
content: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectFileType(filename: string | undefined): 'image' | 'markdown' | 'json' | 'text' | 'other' {
|
||||||
|
const ext = filename?.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
|
||||||
|
if (ext === 'md') return 'markdown';
|
||||||
|
if (ext === 'json') return 'json';
|
||||||
|
if (['txt', 'js', 'ts', 'py', 'css', 'html', 'sh', 'yaml', 'yml', 'toml', 'xml'].includes(ext)) return 'text';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(text: string): string {
|
||||||
|
return 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>')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong class="text-white">$1</strong>')
|
||||||
|
.replace(/\*(.+?)\*/g, '<em class="text-surface-300">$1</em>')
|
||||||
|
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" class="text-brand-400 underline hover:text-brand-300" target="_blank" rel="noopener">$1</a>')
|
||||||
|
.replace(/^- (.+)$/gm, '<li class="text-surface-300 ml-4 list-disc">$1</li>')
|
||||||
|
.replace(/`(.+?)`/g, '<code class="text-accent-cyan bg-surface-800 px-1 py-0.5 rounded text-xs">$1</code>')
|
||||||
|
.replace(/\n\n/g, '</p><p class="text-surface-300 text-sm mb-2">')
|
||||||
|
.replace(/^(?!<[hop])/gm, (match) => match ? match : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJson(text: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
const syntax = JSON.stringify(parsed, null, 2);
|
||||||
|
return syntax;
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function JsonDisplay({ text }: { text: string }) {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return <pre className="text-sm font-mono whitespace-pre-wrap text-surface-300">{text}</pre>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = JSON.stringify(parsed, null, 2);
|
||||||
|
|
||||||
|
const colored = formatted.replace(
|
||||||
|
/("(?:\\.|[^"\\])*")\s*:/g,
|
||||||
|
'<span class="text-accent-cyan">$1</span>:',
|
||||||
|
).replace(
|
||||||
|
/:\s*("(?:\\.|[^"\\])*")/g,
|
||||||
|
':<span class="text-accent-green"> $1</span>',
|
||||||
|
).replace(
|
||||||
|
/:\s*(true|false)/g,
|
||||||
|
':<span class="text-accent-purple"> $1</span>',
|
||||||
|
).replace(
|
||||||
|
/:\s*(\d+\.?\d*)/g,
|
||||||
|
':<span class="text-accent-amber"> $1</span>',
|
||||||
|
).replace(
|
||||||
|
/:\s*(null)/g,
|
||||||
|
':<span class="text-surface-500"> $1</span>',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
className="text-sm font-mono whitespace-pre-wrap text-surface-300"
|
||||||
|
dangerouslySetInnerHTML={{ __html: colored }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilePreview({ cid, content, loading, onClose, filename }: FilePreviewProps) {
|
||||||
|
const fileType = detectFileType(filename);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-white">
|
||||||
|
{filename || cid.slice(0, 16) + '…'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-surface-500 uppercase px-1.5 py-0.5 rounded bg-surface-800">
|
||||||
|
{fileType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-surface-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-pulse space-y-2">
|
||||||
|
<div className="h-3 rounded bg-surface-700 w-full" />
|
||||||
|
<div className="h-3 rounded bg-surface-700 w-5/6" />
|
||||||
|
<div className="h-3 rounded bg-surface-700 w-4/6" />
|
||||||
|
<div className="h-3 rounded bg-surface-700 w-3/6" />
|
||||||
|
</div>
|
||||||
|
) : fileType === 'image' ? (
|
||||||
|
<div className="flex items-center justify-center bg-surface-900 rounded-lg p-2">
|
||||||
|
<img
|
||||||
|
src={`https://ipfs.io/ipfs/${cid}`}
|
||||||
|
alt={filename ?? 'IPFS content'}
|
||||||
|
className="max-w-full max-h-96 rounded object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : fileType === 'markdown' && content ? (
|
||||||
|
<div
|
||||||
|
className="prose prose-invert max-w-none text-sm text-surface-300 max-h-96 overflow-y-auto whitespace-pre-wrap"
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
||||||
|
/>
|
||||||
|
) : fileType === 'json' && content ? (
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
<JsonDisplay text={content} />
|
||||||
|
</div>
|
||||||
|
) : fileType === 'text' && content ? (
|
||||||
|
<pre className="text-sm font-mono whitespace-pre-wrap text-surface-300 max-h-96 overflow-y-auto">
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<p className="text-sm text-surface-500 mb-4">
|
||||||
|
Preview not available for this file type
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={`https://ipfs.io/ipfs/${cid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-800 text-sm text-surface-200 hover:bg-surface-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Download file
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Copy, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface GatewayLinkProps {
|
||||||
|
cid: string;
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GatewayLink({ cid, filename }: GatewayLinkProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const gatewayUrl = `https://ipfs.io/ipfs/${cid}${filename ? `?filename=${encodeURIComponent(filename)}` : ''}`;
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(gatewayUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// clipboard write failed silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<code className="flex-1 truncate text-surface-400 bg-surface-900 px-2 py-1 rounded">
|
||||||
|
{gatewayUrl}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors shrink-0"
|
||||||
|
title="Copy gateway URL"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5 text-surface-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { CheckCircle, Pin } from 'lucide-react';
|
||||||
|
import { addPin } from '@/lib/api';
|
||||||
|
|
||||||
|
interface PinBadgeProps {
|
||||||
|
cid: string;
|
||||||
|
isPinned: boolean;
|
||||||
|
onPin: (cid: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PinBadge({ cid, isPinned, onPin }: PinBadgeProps) {
|
||||||
|
const [pinning, setPinning] = useState(false);
|
||||||
|
|
||||||
|
async function handlePin() {
|
||||||
|
setPinning(true);
|
||||||
|
try {
|
||||||
|
await addPin(cid);
|
||||||
|
onPin(cid);
|
||||||
|
} catch {
|
||||||
|
// pin failed silently
|
||||||
|
} finally {
|
||||||
|
setPinning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPinned) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-accent-green/10 text-accent-green text-xs font-medium">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
Pinned
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handlePin}
|
||||||
|
disabled={pinning}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-surface-600 text-surface-300 text-xs font-medium hover:bg-surface-700 hover:border-surface-500 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Pin className="w-3.5 h-3.5" />
|
||||||
|
{pinning ? 'Pinning…' : 'Pin this CID'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import PortalLayout from '@/app/layout-portal';
|
||||||
|
import { explorerLs, explorerCat } from '@/lib/api';
|
||||||
|
import type { IPFSEntry } from '@/lib/api';
|
||||||
|
import CIDInput from './components/CIDInput';
|
||||||
|
import DirectoryListing from './components/DirectoryListing';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const RECENT_STORAGE_KEY = 'ipfs-explorer-recent-cids';
|
||||||
|
|
||||||
|
interface BreadcrumbSegment {
|
||||||
|
cid: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRecentCids(): string[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as string[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRecentCid(cid: string) {
|
||||||
|
try {
|
||||||
|
const existing = loadRecentCids();
|
||||||
|
const updated = [cid, ...existing.filter((c) => c !== cid)].slice(0, 10);
|
||||||
|
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
} catch {
|
||||||
|
// localStorage write failed silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExplorerState = 'idle' | 'loading' | 'resolving' | 'loaded' | 'error';
|
||||||
|
|
||||||
|
export default function ExplorerPage() {
|
||||||
|
const [cid, setCid] = useState('');
|
||||||
|
const [entries, setEntries] = useState<IPFSEntry[]>([]);
|
||||||
|
const [previewCid, setPreviewCid] = useState<string | null>(null);
|
||||||
|
const [previewContent, setPreviewContent] = useState<string | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [path, setPath] = useState<BreadcrumbSegment[]>([]);
|
||||||
|
const [state, setState] = useState<ExplorerState>('idle');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [pins, setPins] = useState<string[]>([]);
|
||||||
|
const [recentCids] = useState<string[]>(loadRecentCids);
|
||||||
|
|
||||||
|
// Load pins from the pins page (shared localStorage or just track in-memory)
|
||||||
|
function handlePinToggle(cid: string) {
|
||||||
|
setPins((prev) => (prev.includes(cid) ? prev : [...prev, cid]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateTo(targetCid: string) {
|
||||||
|
setCid(targetCid);
|
||||||
|
setPreviewCid(null);
|
||||||
|
setPreviewContent(null);
|
||||||
|
setError('');
|
||||||
|
setState('loading');
|
||||||
|
saveRecentCid(targetCid);
|
||||||
|
|
||||||
|
// Simple IPNS detection — if it contains a dot, assume it's an IPNS name
|
||||||
|
if (targetCid.includes('.') && !targetCid.startsWith('Qm') && !targetCid.startsWith('baf')) {
|
||||||
|
setState('resolving');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await explorerLs(targetCid);
|
||||||
|
setEntries(result);
|
||||||
|
setState('loaded');
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : 'Failed to browse CID';
|
||||||
|
setError(message);
|
||||||
|
setState('error');
|
||||||
|
setEntries([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNavigate(hash: string, name: string) {
|
||||||
|
setPath((prev) => [...prev, { cid: hash, name }]);
|
||||||
|
await navigateTo(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBreadcrumbNavigate(cid: string) {
|
||||||
|
// Find the index of the clicked segment
|
||||||
|
const idx = path.findIndex((s) => s.cid === cid);
|
||||||
|
if (idx >= 0) {
|
||||||
|
setPath(path.slice(0, idx));
|
||||||
|
} else {
|
||||||
|
setPath([]);
|
||||||
|
}
|
||||||
|
await navigateTo(cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePreview(hash: string) {
|
||||||
|
setPreviewCid(hash);
|
||||||
|
setPreviewLoading(true);
|
||||||
|
setPreviewContent(null);
|
||||||
|
try {
|
||||||
|
const content = await explorerCat(hash);
|
||||||
|
setPreviewContent(content);
|
||||||
|
} catch {
|
||||||
|
setPreviewContent(null);
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClosePreview() {
|
||||||
|
setPreviewCid(null);
|
||||||
|
setPreviewContent(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRetry() {
|
||||||
|
if (cid) navigateTo(cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPinned = previewCid ? pins.includes(previewCid) : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalLayout>
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">IPFS Explorer</h1>
|
||||||
|
<p className="text-sm text-surface-400 mt-1">
|
||||||
|
Browse IPFS content by CID — directories, files, and pin management
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CID input */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<CIDInput onNavigate={navigateTo} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent CIDs suggestion */}
|
||||||
|
{state === 'idle' && recentCids.length > 0 && (
|
||||||
|
<div className="mb-6 animate-fade-in">
|
||||||
|
<p className="text-xs text-surface-500 mb-2">Recent CIDs</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{recentCids.slice(0, 5).map((rc) => (
|
||||||
|
<button
|
||||||
|
key={rc}
|
||||||
|
onClick={() => navigateTo(rc)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-surface-800 text-xs font-mono text-surface-400 hover:text-surface-200 hover:bg-surface-700 transition-colors"
|
||||||
|
>
|
||||||
|
{rc.slice(0, 12)}…
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Idle state */}
|
||||||
|
{state === 'idle' && (
|
||||||
|
<div className="glass rounded-xl p-12 flex flex-col items-center justify-center text-center animate-fade-in">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-surface-800 flex items-center justify-center mb-4">
|
||||||
|
<Search className="w-6 h-6 text-surface-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-surface-400 max-w-md">
|
||||||
|
Enter a CID to browse IPFS content — directory listings, file previews, and pin management
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolving IPNS */}
|
||||||
|
{state === 'resolving' && (
|
||||||
|
<div className="glass rounded-xl p-8 flex flex-col items-center justify-center text-center animate-fade-in">
|
||||||
|
<RefreshCw className="w-5 h-5 text-accent-cyan animate-spin mb-3" />
|
||||||
|
<p className="text-sm text-surface-400">Resolving IPNS name...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{state === 'error' && (
|
||||||
|
<div className="glass rounded-xl p-6 animate-fade-in border border-accent-rose/20">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-accent-rose shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-accent-rose mb-1">
|
||||||
|
Failed to browse content
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-surface-400 break-all">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-800 text-xs text-surface-300 hover:bg-surface-700 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loaded state — breadcrumbs + directory listing */}
|
||||||
|
{(state === 'loaded' || state === 'loading') && (
|
||||||
|
<div className="space-y-4 animate-fade-in">
|
||||||
|
{/* Breadcrumbs + Pin + Gateway row */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<Breadcrumbs
|
||||||
|
path={[{ cid, name: cid.slice(0, 12) + '…' }, ...path]}
|
||||||
|
onNavigate={handleBreadcrumbNavigate}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<GatewayLink cid={cid} />
|
||||||
|
<PinBadge cid={cid} isPinned={pins.includes(cid)} onPin={handlePinToggle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Directory listing */}
|
||||||
|
<DirectoryListing
|
||||||
|
entries={entries}
|
||||||
|
loading={state === 'loading'}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onPreview={handlePreview}
|
||||||
|
pins={pins}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* File preview */}
|
||||||
|
{previewCid && (
|
||||||
|
<FilePreview
|
||||||
|
cid={previewCid}
|
||||||
|
content={previewContent}
|
||||||
|
loading={previewLoading}
|
||||||
|
onClose={handleClosePreview}
|
||||||
|
filename={findFilename(entries, previewCid)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PortalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFilename(entries: IPFSEntry[], hash: string): string | undefined {
|
||||||
|
return entries.find((e) => e.hash === hash)?.name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ── Design System Tokens ── */
|
||||||
|
@theme {
|
||||||
|
/* Primary: teal */
|
||||||
|
--color-brand-50: #ecfeff;
|
||||||
|
--color-brand-100: #cffafe;
|
||||||
|
--color-brand-200: #a5f3fc;
|
||||||
|
--color-brand-300: #67e8f9;
|
||||||
|
--color-brand-400: #22d3ee;
|
||||||
|
--color-brand-500: #06b6d4;
|
||||||
|
--color-brand-600: #0891b2;
|
||||||
|
--color-brand-700: #0e7490;
|
||||||
|
--color-brand-800: #155e75;
|
||||||
|
--color-brand-900: #164e63;
|
||||||
|
|
||||||
|
/* Surface (cyber/glass) */
|
||||||
|
--color-surface-50: #f8fafc;
|
||||||
|
--color-surface-100: #f1f5f9;
|
||||||
|
--color-surface-200: #e2e8f0;
|
||||||
|
--color-surface-300: #cbd5e1;
|
||||||
|
--color-surface-400: #94a3b8;
|
||||||
|
--color-surface-500: #64748b;
|
||||||
|
--color-surface-600: #475569;
|
||||||
|
--color-surface-700: #334155;
|
||||||
|
--color-surface-800: #1e293b;
|
||||||
|
--color-surface-850: #172033;
|
||||||
|
--color-surface-900: #0f172a;
|
||||||
|
--color-surface-950: #020617;
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--color-accent-cyan: #22d3ee;
|
||||||
|
--color-accent-purple: #a78bfa;
|
||||||
|
--color-accent-amber: #fbbf24;
|
||||||
|
--color-accent-rose: #fb7185;
|
||||||
|
--color-accent-green: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base ── */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-surface-950);
|
||||||
|
color: var(--color-surface-200);
|
||||||
|
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ── */
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--color-surface-700); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--color-surface-500); }
|
||||||
|
|
||||||
|
/* ── Glass card ── */
|
||||||
|
.glass {
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-hover:hover {
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
border-color: rgba(34, 211, 238, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status dots ── */
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
.status-dot--online { background: var(--color-accent-green); box-shadow: 0 0 6px rgba(52, 211, 153, 0.5); }
|
||||||
|
.status-dot--offline { background: var(--color-surface-500); }
|
||||||
|
.status-dot--error { background: var(--color-accent-rose); box-shadow: 0 0 6px rgba(251, 113, 133, 0.5); }
|
||||||
|
|
||||||
|
/* ── Animations ── */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 4px rgba(34, 211, 238, 0.3); }
|
||||||
|
50% { box-shadow: 0 0 12px rgba(34, 211, 238, 0.6); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in { animation: fade-in 0.4s ease-out both; }
|
||||||
|
.animate-pulse-glow { animation: pulse-glow 2s ease-in-out infinite; }
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { getHistory, clearHistory, getSettings, type UploadRecord } from '@/lib/storage';
|
||||||
|
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,
|
||||||
|
} 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' };
|
||||||
|
case 'eth': return { bg: 'bg-brand-500/10', text: 'text-brand-400', label: 'eth' };
|
||||||
|
case 'token': return { bg: 'bg-accent-purple/10', text: 'text-accent-purple', label: 'token' };
|
||||||
|
case 'quick': return { bg: 'bg-surface-700/50', text: 'text-surface-400', label: 'quick' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════ Page ════════════════════════════ */
|
||||||
|
|
||||||
|
export default function HistoryPage() {
|
||||||
|
const [history, setHistory] = useState<UploadRecord[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setHistory(getHistory());
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
/* ── Filter ── */
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search.trim()) return history;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return history.filter(
|
||||||
|
(r) => r.name.toLowerCase().includes(q) || r.cid.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [history, search]);
|
||||||
|
|
||||||
|
/* ── Stats ── */
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const totalUploads = history.length;
|
||||||
|
const totalSize = history.reduce((sum, r) => sum + r.size, 0);
|
||||||
|
const uniqueCids = new Set(history.map((r) => r.cid)).size;
|
||||||
|
return { totalUploads, totalSize, uniqueCids };
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
/* ── Clear ── */
|
||||||
|
function handleClear() {
|
||||||
|
if (!confirm('Are you sure you want to clear all upload history? This cannot be undone.')) return;
|
||||||
|
clearHistory();
|
||||||
|
setHistory([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Clipboard ── */
|
||||||
|
async function copyToClipboard(text: string, key: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(key);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gateway URL ── */
|
||||||
|
const gatewayUrl = useMemo(() => {
|
||||||
|
try { return getSettings().gatewayUrl; }
|
||||||
|
catch { return 'https://maos.dedyn.io/ipfs'; }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const gatewayLink = (cid: string) => `${gatewayUrl}/${cid}`;
|
||||||
|
|
||||||
|
/* ── Render ── */
|
||||||
|
return (
|
||||||
|
<PortalLayout>
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Upload History</h1>
|
||||||
|
<p className="text-sm text-surface-400 mt-1">
|
||||||
|
{stats.totalUploads > 0
|
||||||
|
? `${stats.totalUploads} upload${stats.totalUploads !== 1 ? 's' : ''} · ${formatBytes(stats.totalSize)} total · ${stats.uniqueCids} unique file${stats.uniqueCids !== 1 ? 's' : ''}`
|
||||||
|
: 'Track your past uploads'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{history.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
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 self-start"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Clear History
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Search ── */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by file name or CID…"
|
||||||
|
className="w-full pl-10 pr-10 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"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Empty state ── */}
|
||||||
|
{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">
|
||||||
|
<Clock className="w-8 h-8 text-surface-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-surface-300 mb-2">No upload history yet</h3>
|
||||||
|
<p className="text-sm text-surface-500 mb-6 max-w-md mx-auto">
|
||||||
|
Your uploaded files will appear here so you can easily copy CIDs, download files, or check upload details.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/upload"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand-600 text-white text-sm font-medium hover:bg-brand-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
Upload your first file
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Desktop table ── */}
|
||||||
|
{filtered.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Desktop table — hidden on small screens */}
|
||||||
|
<div className="hidden sm:block glass rounded-xl overflow-hidden">
|
||||||
|
<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">File Name</th>
|
||||||
|
<th className="px-5 py-3">CID</th>
|
||||||
|
<th className="px-5 py-3">Size</th>
|
||||||
|
<th className="px-5 py-3">Date</th>
|
||||||
|
<th className="px-5 py-3">Method</th>
|
||||||
|
<th className="px-5 py-3 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-surface-800">
|
||||||
|
{filtered.map((rec) => {
|
||||||
|
const badge = getMethodBadge(rec.method);
|
||||||
|
const gwLink = gatewayLink(rec.cid);
|
||||||
|
return (
|
||||||
|
<tr key={rec.cid + rec.date} className="hover:bg-surface-800/30 transition-colors">
|
||||||
|
{/* File name */}
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive className="w-3.5 h-3.5 text-surface-500 shrink-0" />
|
||||||
|
<span className="text-sm text-surface-200 truncate max-w-[200px]" title={rec.name}>
|
||||||
|
{rec.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* CID */}
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span
|
||||||
|
className="text-xs font-mono text-surface-400 cursor-default"
|
||||||
|
title={rec.cid}
|
||||||
|
>
|
||||||
|
{truncateCid(rec.cid)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Size */}
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span className="text-xs text-surface-400">{formatBytes(rec.size)}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div className="text-xs text-surface-400">
|
||||||
|
<div>{new Date(rec.date).toLocaleDateString()}</div>
|
||||||
|
<div className="text-surface-500">{new Date(rec.date).toLocaleTimeString()}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Method badge */}
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium ${badge.bg} ${badge.text}`}>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<td className="px-5 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{/* Copy CID */}
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(rec.cid, `cid-${rec.cid}`)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
|
||||||
|
title="Copy CID"
|
||||||
|
>
|
||||||
|
{copied === `cid-${rec.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>
|
||||||
|
|
||||||
|
{/* Copy gateway link */}
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(gwLink, `gw-${rec.cid}`)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
|
||||||
|
title="Copy gateway link"
|
||||||
|
>
|
||||||
|
{copied === `gw-${rec.cid}` ? (
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||||
|
) : (
|
||||||
|
<LinkIcon className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Open in gateway */}
|
||||||
|
<a
|
||||||
|
href={gwLink}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Mobile cards ── */}
|
||||||
|
<div className="sm:hidden space-y-3">
|
||||||
|
{filtered.map((rec) => {
|
||||||
|
const badge = getMethodBadge(rec.method);
|
||||||
|
const gwLink = gatewayLink(rec.cid);
|
||||||
|
return (
|
||||||
|
<div key={rec.cid + rec.date} className="glass rounded-xl p-4 space-y-3">
|
||||||
|
{/* Name + method badge */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<HardDrive className="w-4 h-4 text-surface-500 shrink-0" />
|
||||||
|
<span className="text-sm text-surface-200 truncate" title={rec.name}>
|
||||||
|
{rec.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className={`shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium ${badge.bg} ${badge.text}`}>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CID */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="text-xs font-mono text-surface-400 truncate"
|
||||||
|
title={rec.cid}
|
||||||
|
>
|
||||||
|
{truncateCid(rec.cid)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(rec.cid, `cid-${rec.cid}`)}
|
||||||
|
className="p-1 rounded-lg hover:bg-surface-700 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
{copied === `cid-${rec.cid}` ? (
|
||||||
|
<CheckCircle className="w-3 h-3 text-accent-green" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3 h-3 text-surface-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size + date */}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-surface-500">
|
||||||
|
<span>{formatBytes(rec.size)}</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{new Date(rec.date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{new Date(rec.date).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions row */}
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
{/* Copy CID */}
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(rec.cid, `cid-m-${rec.cid}`)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
Copy CID
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Copy link */}
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(gwLink, `gw-m-${rec.cid}`)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-3 h-3" />
|
||||||
|
Copy Link
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Download / Open */}
|
||||||
|
<a
|
||||||
|
href={gwLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Results count ── */}
|
||||||
|
{search && filtered.length < history.length && (
|
||||||
|
<div className="mt-3 text-center text-xs text-surface-500">
|
||||||
|
Showing {filtered.length} of {history.length} uploads
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="ml-2 text-brand-400 hover:text-brand-300 underline transition-colors"
|
||||||
|
>
|
||||||
|
Clear filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── No search results ── */}
|
||||||
|
{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" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-semibold text-surface-300 mb-1">No results found</h3>
|
||||||
|
<p className="text-sm text-surface-500">
|
||||||
|
No uploads match “{search}”
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="mt-4 text-sm text-brand-400 hover:text-brand-300 underline transition-colors"
|
||||||
|
>
|
||||||
|
Clear search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PortalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { listIPNSKeys, ipnsPublish, ipnsResolve, ipnsGenKey, type IPNSKey } from '@/lib/api';
|
||||||
|
import { RefreshCw, ExternalLink, Key, FileText, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
type Status = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export default function IPNSPage() {
|
||||||
|
const [keys, setKeys] = useState<IPNSKey[]>([]);
|
||||||
|
const [status, setStatus] = useState<Status>('loading');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Publish form
|
||||||
|
const [pubCid, setPubCid] = useState('');
|
||||||
|
const [pubKey, setPubKey] = useState('self');
|
||||||
|
const [pubResult, setPubResult] = useState<{ name: string; value: string } | null>(null);
|
||||||
|
const [pubLoading, setPubLoading] = useState(false);
|
||||||
|
const [pubError, setPubError] = useState('');
|
||||||
|
|
||||||
|
// Resolve form
|
||||||
|
const [resolveName, setResolveName] = useState('');
|
||||||
|
const [resolveResult, setResolveResult] = useState('');
|
||||||
|
const [resolveLoading, setResolveLoading] = useState(false);
|
||||||
|
const [resolveError, setResolveError] = useState('');
|
||||||
|
|
||||||
|
// Gen key form
|
||||||
|
const [genName, setGenName] = useState('');
|
||||||
|
const [genLoading, setGenLoading] = useState(false);
|
||||||
|
const [genError, setGenError] = useState('');
|
||||||
|
|
||||||
|
const loadKeys = useCallback(async () => {
|
||||||
|
setStatus('loading');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const k = await listIPNSKeys();
|
||||||
|
setKeys(k);
|
||||||
|
setStatus('success');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load keys');
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadKeys(); }, [loadKeys]);
|
||||||
|
|
||||||
|
const handlePublish = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!pubCid.trim()) return;
|
||||||
|
setPubLoading(true);
|
||||||
|
setPubError('');
|
||||||
|
setPubResult(null);
|
||||||
|
try {
|
||||||
|
const res = await ipnsPublish(pubCid.trim(), pubKey);
|
||||||
|
setPubResult(res);
|
||||||
|
} catch (e) {
|
||||||
|
setPubError(e instanceof Error ? e.message : 'Publish failed');
|
||||||
|
} finally {
|
||||||
|
setPubLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResolve = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!resolveName.trim()) return;
|
||||||
|
setResolveLoading(true);
|
||||||
|
setResolveError('');
|
||||||
|
setResolveResult('');
|
||||||
|
try {
|
||||||
|
const path = await ipnsResolve(resolveName.trim());
|
||||||
|
setResolveResult(path);
|
||||||
|
} catch (e) {
|
||||||
|
setResolveError(e instanceof Error ? e.message : 'Resolve failed');
|
||||||
|
} finally {
|
||||||
|
setResolveLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenKey = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!genName.trim()) return;
|
||||||
|
setGenLoading(true);
|
||||||
|
setGenError('');
|
||||||
|
try {
|
||||||
|
await ipnsGenKey(genName.trim());
|
||||||
|
setGenName('');
|
||||||
|
await loadKeys();
|
||||||
|
} catch (e) {
|
||||||
|
setGenError(e instanceof Error ? e.message : 'Key generation failed');
|
||||||
|
} finally {
|
||||||
|
setGenLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl 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>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadKeys}
|
||||||
|
className="btn-ghost p-2 rounded-lg text-surface-400 hover:text-surface-200"
|
||||||
|
title="Refresh keys"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${status === 'loading' ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keys */}
|
||||||
|
<section className="glass rounded-xl border border-surface-800 p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-sm font-semibold text-surface-200 flex items-center gap-2">
|
||||||
|
<Key className="w-4 h-4 text-brand-400" />
|
||||||
|
Signing Keys
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-surface-500">{keys.length} key{keys.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-surface-400 py-4">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
Loading keys...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="text-sm text-accent-rose bg-accent-rose/5 border border-accent-rose/20 rounded-lg p-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && keys.length === 0 && (
|
||||||
|
<p className="text-sm text-surface-500 py-2">No IPNS keys found.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{keys.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<div
|
||||||
|
key={key.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg bg-surface-900/50 border border-surface-800/50 hover:border-surface-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-surface-200 truncate">{key.name}</p>
|
||||||
|
<p className="text-xs text-surface-500 font-mono truncate mt-0.5">{key.id}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setResolveName(key.id)}
|
||||||
|
className="btn-ghost p-1.5 rounded-md text-surface-500 hover:text-surface-300 shrink-0 ml-3"
|
||||||
|
title="Resolve this key"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Generate key */}
|
||||||
|
<form onSubmit={handleGenKey} className="mt-4 pt-4 border-t border-surface-800">
|
||||||
|
<label className="text-xs font-medium text-surface-500 mb-1.5 block">Generate New Key</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={genName}
|
||||||
|
onChange={(e) => setGenName(e.target.value)}
|
||||||
|
placeholder="key-name"
|
||||||
|
className="flex-1 px-3 py-1.5 text-sm bg-surface-900 border border-surface-700 rounded-lg text-surface-200 placeholder-surface-600 focus:outline-none focus:border-brand-500/50"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={genLoading || !genName.trim()}
|
||||||
|
className="btn-primary px-4 py-1.5 text-sm rounded-lg disabled:opacity-40 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{genLoading ? (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{genError && <p className="text-xs text-accent-rose mt-1">{genError}</p>}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Two-column: Publish + Resolve */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Publish */}
|
||||||
|
<section className="glass rounded-xl border border-surface-800 p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-surface-200 flex items-center gap-2 mb-4">
|
||||||
|
<FileText className="w-4 h-4 text-brand-400" />
|
||||||
|
Publish
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-surface-500 mb-4">
|
||||||
|
Point an IPNS name to a CID. Publish is signed with the selected key.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handlePublish} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-surface-500 mb-1 block">CID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pubCid}
|
||||||
|
onChange={(e) => setPubCid(e.target.value)}
|
||||||
|
placeholder="Qm... or bafy..."
|
||||||
|
className="w-full px-3 py-1.5 text-sm bg-surface-900 border border-surface-700 rounded-lg text-surface-200 placeholder-surface-600 focus:outline-none focus:border-brand-500/50 font-mono"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-surface-500 mb-1 block">Key</label>
|
||||||
|
<select
|
||||||
|
value={pubKey}
|
||||||
|
onChange={(e) => setPubKey(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 text-sm bg-surface-900 border border-surface-700 rounded-lg text-surface-200 focus:outline-none focus:border-brand-500/50"
|
||||||
|
>
|
||||||
|
{keys.map((k) => (
|
||||||
|
<option key={k.id} value={k.name}>{k.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pubLoading || !pubCid.trim()}
|
||||||
|
className="btn-primary w-full py-2 text-sm rounded-lg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{pubLoading ? 'Publishing...' : 'Publish'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{pubError && <p className="text-xs text-accent-rose mt-2">{pubError}</p>}
|
||||||
|
{pubResult && (
|
||||||
|
<div className="mt-3 p-3 rounded-lg bg-surface-900/50 border border-surface-800 text-xs space-y-1">
|
||||||
|
<p className="text-surface-400">Name: <span className="text-surface-200 font-mono">{pubResult.name}</span></p>
|
||||||
|
<p className="text-surface-400">Value: <span className="text-surface-200 font-mono break-all">{pubResult.value}</span></p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Resolve */}
|
||||||
|
<section className="glass rounded-xl border border-surface-800 p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-surface-200 flex items-center gap-2 mb-4">
|
||||||
|
<ExternalLink className="w-4 h-4 text-brand-400" />
|
||||||
|
Resolve
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-surface-500 mb-4">
|
||||||
|
Resolve an IPNS name or peer ID to the current IPFS path.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleResolve} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-surface-500 mb-1 block">Name / Peer ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={resolveName}
|
||||||
|
onChange={(e) => setResolveName(e.target.value)}
|
||||||
|
placeholder="k51... or /ipns/example.com"
|
||||||
|
className="w-full px-3 py-1.5 text-sm bg-surface-900 border border-surface-700 rounded-lg text-surface-200 placeholder-surface-600 focus:outline-none focus:border-brand-500/50 font-mono"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={resolveLoading || !resolveName.trim()}
|
||||||
|
className="btn-primary w-full py-2 text-sm rounded-lg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{resolveLoading ? 'Resolving...' : 'Resolve'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{resolveError && <p className="text-xs text-accent-rose mt-2">{resolveError}</p>}
|
||||||
|
{resolveResult && (
|
||||||
|
<div className="mt-3 p-3 rounded-lg bg-surface-900/50 border border-surface-800 text-xs">
|
||||||
|
<p className="text-surface-400">Resolved to:</p>
|
||||||
|
<p className="text-surface-200 font-mono break-all mt-1">{resolveResult}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import PortalSidebar from '@/components/PortalSidebar';
|
||||||
|
import PortalHeader from '@/components/PortalHeader';
|
||||||
|
|
||||||
|
export default function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<PortalSidebar />
|
||||||
|
<div className="flex-1 flex flex-col ml-56">
|
||||||
|
<PortalHeader />
|
||||||
|
<main className="flex-1 p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'MAOS IPFS Portal',
|
||||||
|
description: 'Decentralized storage management for your IPFS node',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
themeColor: '#020617',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body className="min-h-screen antialiased">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { connectWallet, signMessage } from '@/lib/wallet';
|
||||||
|
import { checkHealth } from '@/lib/api';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { HardDrive, Globe, Lock, ArrowRight, Server, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [address, setAddress] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleConnect() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const wallet = await connectWallet();
|
||||||
|
setAddress(wallet.address);
|
||||||
|
// Non-blocking health check — portal UI werkt ook zonder backend
|
||||||
|
checkHealth().catch(() => {});
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Connection failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center p-4 relative overflow-hidden">
|
||||||
|
{/* Background glow */}
|
||||||
|
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[600px] h-[600px] rounded-full bg-brand-500/5 blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-[400px] h-[400px] rounded-full bg-accent-purple/5 blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="relative text-center max-w-2xl animate-fade-in">
|
||||||
|
{/* Badge */}
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full glass text-sm text-surface-400 mb-8">
|
||||||
|
<Server className="w-3.5 h-3.5 text-accent-cyan" />
|
||||||
|
<span>MAOSv6 · IPFS Gateway</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="text-5xl sm:text-6xl font-bold tracking-tight text-white mb-4">
|
||||||
|
IPFS{' '}
|
||||||
|
<span className="bg-gradient-to-r from-accent-cyan to-accent-purple bg-clip-text text-transparent">
|
||||||
|
Portal
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-surface-400 mb-8 max-w-md mx-auto leading-relaxed">
|
||||||
|
Decentralized storage management. Pin files, monitor peers, manage users.
|
||||||
|
All from your wallet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Feature pills */}
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mb-10">
|
||||||
|
{[
|
||||||
|
{ icon: HardDrive, label: 'Pin Manager' },
|
||||||
|
{ icon: Globe, label: 'Peer Monitor' },
|
||||||
|
{ icon: Zap, label: 'File Upload' },
|
||||||
|
{ icon: Lock, label: 'Wallet Auth' },
|
||||||
|
].map((f) => (
|
||||||
|
<div
|
||||||
|
key={f.label}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-full glass text-sm text-surface-300"
|
||||||
|
>
|
||||||
|
<f.icon className="w-3.5 h-3.5 text-accent-cyan" />
|
||||||
|
{f.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connect button */}
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={loading}
|
||||||
|
className="group inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-brand-600 to-brand-500 text-white font-medium text-sm hover:from-brand-500 hover:to-brand-400 transition-all duration-200 shadow-lg shadow-brand-500/25 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
Connecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Connect Wallet
|
||||||
|
<ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="absolute bottom-6 text-xs text-surface-600">
|
||||||
|
MAOSv6 · Chain 270
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getPeers } from '@/lib/api';
|
||||||
|
import PortalLayout from '@/app/layout-portal';
|
||||||
|
import { Wifi, Globe, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function PeersPage() {
|
||||||
|
const [peers, setPeers] = useState<{ id: string; addr: string; latency: string }[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const items = await getPeers();
|
||||||
|
setPeers(items);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
const iv = setInterval(load, 15_000);
|
||||||
|
return () => clearInterval(iv);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalLayout>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Peers</h1>
|
||||||
|
<p className="text-sm text-surface-400 mt-1">{peers.length} connected peers</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass rounded-xl overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-sm text-surface-500">Loading…</div>
|
||||||
|
) : 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 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" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-mono text-surface-200 truncate">{p.id}</div>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5 text-xs text-surface-500">
|
||||||
|
<span className="truncate max-w-[200px]">{p.addr}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-surface-500 shrink-0">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>{p.latency}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PortalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
export default function PinsPage() {
|
||||||
|
const [pins, setPins] = useState<{ cid: string; name: string; size: number; created: string }[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [newCid, setNewCid] = useState('');
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const items = await listPins();
|
||||||
|
setPins(items);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
if (!newCid) return;
|
||||||
|
try {
|
||||||
|
await addPin(newCid, newName || undefined);
|
||||||
|
setNewCid('');
|
||||||
|
setNewName('');
|
||||||
|
setShowAdd(false);
|
||||||
|
await load();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(cid: string) {
|
||||||
|
try {
|
||||||
|
await removePin(cid);
|
||||||
|
setPins((p) => p.filter((x) => x.cid !== cid));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCid(cid: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(cid);
|
||||||
|
setCopied(cid);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = pins.filter(
|
||||||
|
(p) => p.cid.includes(search) || (p.name && p.name.toLowerCase().includes(search.toLowerCase())),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalLayout>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Pins</h1>
|
||||||
|
<p className="text-sm text-surface-400 mt-1">{pins.length} pinned CIDs</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"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Pin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add pin 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={newCid}
|
||||||
|
onChange={(e) => setNewCid(e.target.value)}
|
||||||
|
placeholder="CID to pin…"
|
||||||
|
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={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="Name (optional)"
|
||||||
|
className="w-40 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={handleAdd}
|
||||||
|
disabled={!newCid}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Pin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search pins…"
|
||||||
|
className="w-full pl-10 pr-4 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pin list */}
|
||||||
|
<div className="glass rounded-xl overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-sm text-surface-500">Loading…</div>
|
||||||
|
) : 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.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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">
|
||||||
|
<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" />
|
||||||
|
<span className="text-sm font-mono text-surface-200 truncate">
|
||||||
|
{pin.name || pin.cid}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-surface-500">
|
||||||
|
<span>{(pin.size / 1024).toFixed(1)} KB</span>
|
||||||
|
{pin.created && <span>{pin.created}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/explorer?cid=${pin.cid}`}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 text-surface-400" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => copyCid(pin.cid)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
||||||
|
>
|
||||||
|
{copied === pin.cid ? (
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5 text-surface-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(pin.cid)}
|
||||||
|
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>
|
||||||
|
</PortalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { getSettings, saveSettings, resetSettings, type PortalSettings } from '@/lib/storage';
|
||||||
|
import PortalLayout from '@/app/layout-portal';
|
||||||
|
import {
|
||||||
|
Settings, Globe, Server, HardDrive, RefreshCw,
|
||||||
|
Sun, Moon, Save, RotateCcw, Check, AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/* ── Helpers ── */
|
||||||
|
|
||||||
|
function formatStorageMB(mb: number): string {
|
||||||
|
if (mb >= 1024) return (mb / 1024).toFixed(0) + ' GB';
|
||||||
|
return mb + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════ Page ════════════════════════════ */
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [form, setForm] = useState<PortalSettings>(() => getSettings());
|
||||||
|
const [original, setOriginal] = useState<PortalSettings>(() => getSettings());
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [validation, setValidation] = useState<Record<string, string | null>>({});
|
||||||
|
|
||||||
|
const dirty = JSON.stringify(form) !== JSON.stringify(original);
|
||||||
|
|
||||||
|
/* ── Field update ── */
|
||||||
|
function update<K extends keyof PortalSettings>(key: K, value: PortalSettings[K]) {
|
||||||
|
setForm(prev => ({ ...prev, [key]: value }));
|
||||||
|
setSaved(false);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Clear validation error on change
|
||||||
|
if (validation[key]) {
|
||||||
|
setValidation(prev => ({ ...prev, [key]: null }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Validate ── */
|
||||||
|
function validate(): boolean {
|
||||||
|
const errs: Record<string, string | null> = {};
|
||||||
|
let valid = true;
|
||||||
|
|
||||||
|
if (!form.gatewayUrl.startsWith('http://') && !form.gatewayUrl.startsWith('https://')) {
|
||||||
|
errs.gatewayUrl = 'Must start with http:// or https://';
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
if (form.gatewayUrl.length > 500) {
|
||||||
|
errs.gatewayUrl = 'Too long (max 500 chars)';
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
if (form.apiEndpoint && !form.apiEndpoint.startsWith('/') && !form.apiEndpoint.startsWith('http')) {
|
||||||
|
errs.apiEndpoint = 'Must be empty, a path (/…), or a URL';
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
if (form.storageMax < 1 || form.storageMax > 102400) {
|
||||||
|
errs.storageMax = 'Must be 1–102400 MB';
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
if (form.refreshInterval < 5 || form.refreshInterval > 300) {
|
||||||
|
errs.refreshInterval = 'Must be 5–300 seconds';
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidation(errs);
|
||||||
|
if (!valid) setError('Fix validation errors before saving');
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Save ── */
|
||||||
|
function handleSave() {
|
||||||
|
if (!validate()) return;
|
||||||
|
const merged = saveSettings(form);
|
||||||
|
setForm(merged);
|
||||||
|
setOriginal({ ...merged });
|
||||||
|
setSaved(true);
|
||||||
|
setError(null);
|
||||||
|
setTimeout(() => setSaved(false), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reset ── */
|
||||||
|
function handleReset() {
|
||||||
|
if (!confirm('Reset all settings to defaults?')) return;
|
||||||
|
const defaults = resetSettings();
|
||||||
|
setForm(defaults);
|
||||||
|
setOriginal({ ...defaults });
|
||||||
|
setSaved(false);
|
||||||
|
setError(null);
|
||||||
|
setValidation({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Render helpers ── */
|
||||||
|
|
||||||
|
function inputCls(field: string): string {
|
||||||
|
return `w-full rounded-lg bg-surface-900 border ${validation[field] ? 'border-accent-rose/50' : 'border-surface-700'} px-3 py-2 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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMsg(field: string) {
|
||||||
|
return validation[field] ? (
|
||||||
|
<p className="mt-1 text-xs text-accent-rose">{validation[field]}</p>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════ Sections ════════════ */
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
title: 'IPFS Gateway',
|
||||||
|
icon: Globe,
|
||||||
|
iconColor: 'text-accent-cyan',
|
||||||
|
fields: (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-400 mb-1.5 block">Gateway URL</label>
|
||||||
|
<input
|
||||||
|
value={form.gatewayUrl}
|
||||||
|
onChange={e => update('gatewayUrl', e.target.value)}
|
||||||
|
placeholder="https://ipfs.io/ipfs"
|
||||||
|
className={inputCls('gatewayUrl')}
|
||||||
|
/>
|
||||||
|
{errorMsg('gatewayUrl')}
|
||||||
|
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||||
|
Base URL for IPFS gateway links. Used in dashboard, history, and share links.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'API Endpoint',
|
||||||
|
icon: Server,
|
||||||
|
iconColor: 'text-accent-purple',
|
||||||
|
fields: (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-400 mb-1.5 block">API Base URL</label>
|
||||||
|
<input
|
||||||
|
value={form.apiEndpoint}
|
||||||
|
onChange={e => update('apiEndpoint', e.target.value)}
|
||||||
|
placeholder="(same-origin)"
|
||||||
|
className={inputCls('apiEndpoint')}
|
||||||
|
/>
|
||||||
|
{errorMsg('apiEndpoint')}
|
||||||
|
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||||
|
Leave empty to use the same origin. Set to a custom URL to proxy through another server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Storage',
|
||||||
|
icon: HardDrive,
|
||||||
|
iconColor: 'text-accent-amber',
|
||||||
|
fields: (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-400 mb-1.5 block">
|
||||||
|
Max Storage: <span className="text-surface-200 font-medium">{formatStorageMB(form.storageMax)}</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1024}
|
||||||
|
max={102400}
|
||||||
|
step={1024}
|
||||||
|
value={form.storageMax}
|
||||||
|
onChange={e => update('storageMax', Number(e.target.value))}
|
||||||
|
className="flex-1 accent-brand-500 h-1.5 rounded-full appearance-none bg-surface-700 cursor-pointer"
|
||||||
|
style={{ accentColor: '#14b8a6' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={102400}
|
||||||
|
value={form.storageMax}
|
||||||
|
onChange={e => update('storageMax', Math.max(1, Math.min(102400, Number(e.target.value) || 0)))}
|
||||||
|
className="w-20 rounded-lg bg-surface-900 border border-surface-700 px-2 py-1.5 text-xs text-surface-200 text-center focus:outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-surface-500">MB</span>
|
||||||
|
</div>
|
||||||
|
{errorMsg('storageMax')}
|
||||||
|
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||||
|
Maximum storage the IPFS node should use. Applied server-side.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Auto-Refresh',
|
||||||
|
icon: RefreshCw,
|
||||||
|
iconColor: 'text-accent-green',
|
||||||
|
fields: (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-400 mb-1.5 block">
|
||||||
|
Dashboard Refresh: <span className="text-surface-200 font-medium">{form.refreshInterval}s</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={5}
|
||||||
|
max={120}
|
||||||
|
step={5}
|
||||||
|
value={form.refreshInterval}
|
||||||
|
onChange={e => update('refreshInterval', Number(e.target.value))}
|
||||||
|
className="flex-1 accent-brand-500 h-1.5 rounded-full appearance-none bg-surface-700 cursor-pointer"
|
||||||
|
style={{ accentColor: '#14b8a6' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
max={120}
|
||||||
|
value={form.refreshInterval}
|
||||||
|
onChange={e => update('refreshInterval', Math.max(5, Math.min(120, Number(e.target.value) || 0)))}
|
||||||
|
className="w-16 rounded-lg bg-surface-900 border border-surface-700 px-2 py-1.5 text-xs text-surface-200 text-center focus:outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-surface-500">sec</span>
|
||||||
|
</div>
|
||||||
|
{errorMsg('refreshInterval')}
|
||||||
|
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||||
|
How often the dashboard auto-refreshes peer/bandwidth data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Theme',
|
||||||
|
icon: form.theme === 'dark' ? Moon : Sun,
|
||||||
|
iconColor: 'text-accent-amber',
|
||||||
|
fields: (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-400 mb-2 block">Appearance</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['dark', 'light'] as const).map(theme => (
|
||||||
|
<button
|
||||||
|
key={theme}
|
||||||
|
onClick={() => update('theme', theme)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
form.theme === theme
|
||||||
|
? '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)}
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalLayout>
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||||
|
<p className="text-sm text-surface-400 mt-1">IPFS Portal configuration</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Settings grid ── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
{sections.map((s) => (
|
||||||
|
<div key={s.title} className="glass rounded-xl p-5 animate-fade-in">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<s.icon className={`w-4 h-4 ${s.iconColor}`} />
|
||||||
|
<h2 className="text-sm font-semibold text-white">{s.title}</h2>
|
||||||
|
</div>
|
||||||
|
{s.fields}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Actions + status ── */}
|
||||||
|
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Save */}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!dirty && !error}
|
||||||
|
className="flex items-center gap-2 px-5 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"
|
||||||
|
>
|
||||||
|
{saved ? (
|
||||||
|
<><Check className="w-4 h-4 text-accent-green" /> Saved!</>
|
||||||
|
) : (
|
||||||
|
<><Save className="w-4 h-4" /> Save Settings</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Reset */}
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-800 hover:bg-surface-700 text-surface-300 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Reset Defaults
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dirty indicator */}
|
||||||
|
{dirty && !saved && (
|
||||||
|
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-amber/10 text-accent-amber text-xs">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Unsaved changes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-rose/10 text-accent-rose text-xs">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PortalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, DragEvent, useCallback } from 'react';
|
||||||
|
import { uploadFile } from '@/lib/api';
|
||||||
|
import { addToHistory, type UploadRecord } 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';
|
||||||
|
|
||||||
|
/* ── 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 inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const cryptoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
/* ── Tab state ── */
|
||||||
|
const [tab, setTab] = useState<TabMode>('quick');
|
||||||
|
|
||||||
|
/* ── Quick Upload state ── */
|
||||||
|
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadIndex, setUploadIndex] = useState(0);
|
||||||
|
const [uploadDone, setUploadDone] = useState(false);
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/* ── Crypto Upload state ── */
|
||||||
|
const [cryptoFile, setCryptoFile] = useState<File | null>(null);
|
||||||
|
const [cryptoResult, setCryptoResult] = useState<{ cid: string; size: number } | null>(null);
|
||||||
|
|
||||||
|
/* ── File management ── */
|
||||||
|
const addFiles = useCallback((incoming: FileList | File[]) => {
|
||||||
|
const arr = Array.from(incoming);
|
||||||
|
const remaining = MAX_FILES - files.length;
|
||||||
|
const toAdd = arr.slice(0, remaining);
|
||||||
|
|
||||||
|
const entries: FileEntry[] = toAdd
|
||||||
|
.filter(f => f.size <= MAX_FILE_SIZE)
|
||||||
|
.filter(f => !files.some(ex => ex.file.name === f.name && ex.file.size === f.size))
|
||||||
|
.map(f => ({
|
||||||
|
id: Math.random().toString(36).slice(2, 9),
|
||||||
|
file: f,
|
||||||
|
preview: ALLOWED_TYPES.includes(f.type) ? URL.createObjectURL(f) : undefined,
|
||||||
|
status: 'pending' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
setFiles(prev => [...prev, ...entries]);
|
||||||
|
setUploadDone(false);
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
function removeFile(id: string) {
|
||||||
|
setFiles(prev => {
|
||||||
|
const entry = prev.find(f => f.id === id);
|
||||||
|
if (entry?.preview) URL.revokeObjectURL(entry.preview);
|
||||||
|
return prev.filter(f => f.id !== id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drag / Drop ── */
|
||||||
|
function onDragOver(e: DragEvent) { e.preventDefault(); setDragOver(true); }
|
||||||
|
function onDragLeave() { setDragOver(false); }
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
function onBrowse() { inputRef.current?.click(); }
|
||||||
|
function onInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
addFiles(e.target.files);
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sequential Upload ── */
|
||||||
|
async function startUpload() {
|
||||||
|
const pending = files.filter(f => f.status === 'pending');
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setUploadIndex(0);
|
||||||
|
setUploadDone(false);
|
||||||
|
|
||||||
|
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
|
||||||
|
));
|
||||||
|
} catch (e: any) {
|
||||||
|
setFiles(prev => prev.map(f =>
|
||||||
|
f.id === entry.id ? { ...f, status: 'error' as const, errorMsg: e.message || 'Upload failed' } : f
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(false);
|
||||||
|
setUploadDone(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Crypto flow ── */
|
||||||
|
function onCryptoSelect() { cryptoInputRef.current?.click(); }
|
||||||
|
function onCryptoFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setCryptoFile(file);
|
||||||
|
setCryptoResult(null);
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
function onCryptoClear() {
|
||||||
|
setCryptoFile(null);
|
||||||
|
setCryptoResult(null);
|
||||||
|
}
|
||||||
|
async function doCryptoUpload(): Promise<{ cid: string; size: number }> {
|
||||||
|
if (!cryptoFile) throw new Error('No file selected');
|
||||||
|
const r = await uploadFile(cryptoFile);
|
||||||
|
setCryptoResult(r);
|
||||||
|
addToHistory({ cid: r.cid, name: cryptoFile.name, size: r.size, date: new Date().toISOString(), method: 'eth' });
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
function onCryptoPaid(_info: { cid: string; txHash?: string; method: string }) {
|
||||||
|
// handled in doCryptoUpload
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Clipboard ── */
|
||||||
|
async function copyCid(cid: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(cid);
|
||||||
|
setCopied(cid);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<PortalLayout>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Upload</h1>
|
||||||
|
<p className="text-sm text-surface-400 mt-1">Upload files to IPFS — quick or with crypto payment</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tab switcher ── */}
|
||||||
|
<div className="flex gap-1 mb-6 p-1 rounded-xl bg-surface-900/70 border border-surface-800 w-fit">
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('quick')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
tab === 'quick'
|
||||||
|
? 'bg-brand-500/15 text-brand-400 shadow-sm'
|
||||||
|
: 'text-surface-400 hover:text-surface-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
Quick Upload
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('crypto')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
tab === 'crypto'
|
||||||
|
? 'bg-brand-500/15 text-brand-400 shadow-sm'
|
||||||
|
: 'text-surface-400 hover:text-surface-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowUp className="w-4 h-4" />
|
||||||
|
Crypto Payment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ════════════ TAB: Quick Upload ════════════ */}
|
||||||
|
{tab === 'quick' && (
|
||||||
|
<div>
|
||||||
|
{/* ── Drop zone ── */}
|
||||||
|
{!uploading && !uploadDone && (
|
||||||
|
<div
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={files.length < MAX_FILES ? onDrop : undefined}
|
||||||
|
onClick={files.length < MAX_FILES ? onBrowse : undefined}
|
||||||
|
className={`relative border-2 border-dashed rounded-xl p-10 text-center transition-all duration-200 cursor-pointer ${
|
||||||
|
dragOver
|
||||||
|
? 'border-brand-400 bg-brand-500/10'
|
||||||
|
: 'border-surface-700 hover:border-surface-500 bg-surface-900/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={onInputChange}
|
||||||
|
disabled={uploading || files.length >= MAX_FILES}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Upload className="w-10 h-10 text-surface-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-surface-300">
|
||||||
|
<span className="text-brand-400 font-medium">Click to browse</span> or drag & 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 ════════════ */}
|
||||||
|
{tab === 'crypto' && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Left: file selection */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{!cryptoFile ? (
|
||||||
|
<div
|
||||||
|
onClick={onCryptoSelect}
|
||||||
|
className="border-2 border-dashed border-surface-700 hover:border-surface-500 rounded-xl p-12 text-center cursor-pointer transition-all bg-surface-900/50"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={cryptoInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={onCryptoFileChange}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<ArrowUp className="w-10 h-10 text-surface-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-surface-300">
|
||||||
|
<span className="text-brand-400 font-medium">Click to select</span> a file
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-surface-500 mt-1">
|
||||||
|
Single file only. Pay with ETH, USDC, or MAOS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="glass rounded-xl p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<File className="w-8 h-8 text-brand-400" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-surface-200 font-medium truncate">{cryptoFile.name}</p>
|
||||||
|
<p className="text-xs text-surface-500">{formatBytes(cryptoFile.size)}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onCryptoClear}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-surface-500 hover:text-accent-rose" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Crypto result */}
|
||||||
|
{cryptoResult && (
|
||||||
|
<div className="mt-4 space-y-2 p-3 rounded-lg bg-accent-green/10 border border-accent-green/20 animate-fade-in">
|
||||||
|
<div className="flex items-center gap-2 text-accent-green">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-medium">Upload complete</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-surface-400">CID</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-mono text-surface-300 truncate max-w-[180px]">{cryptoResult.cid}</span>
|
||||||
|
<button onClick={() => copyCid(cryptoResult.cid)} className="p-0.5 hover:text-surface-100">
|
||||||
|
{copied === cryptoResult.cid ? (
|
||||||
|
<CheckCircle className="w-3 h-3 text-accent-green" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3 h-3 text-surface-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-surface-400">Gateway</span>
|
||||||
|
<a
|
||||||
|
href={gatewayLink(cryptoResult.cid)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-brand-400 hover:text-brand-300"
|
||||||
|
>
|
||||||
|
View <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: PaymentPanel */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
{cryptoFile ? (
|
||||||
|
<PaymentPanel
|
||||||
|
fileSize={cryptoFile.size}
|
||||||
|
fileName={cryptoFile.name}
|
||||||
|
onPaid={onCryptoPaid}
|
||||||
|
onUpload={doCryptoUpload}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="glass rounded-xl p-5 text-center">
|
||||||
|
<ArrowUp className="w-6 h-6 text-surface-600 mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-surface-500">
|
||||||
|
Select a file to see pricing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PortalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { paymentService, type PriceInfo, type TokenInfo, type MAOSDiscountTier, type UserUploadStats } from '@/lib/payment';
|
||||||
|
import {
|
||||||
|
connectWallet, switchToChain270, getInjectedProvider,
|
||||||
|
formatWeiToETH, formatBytes, type WalletProvider,
|
||||||
|
} from '@/lib/wallet';
|
||||||
|
import {
|
||||||
|
Wallet, Coins, Loader2, CheckCircle, XCircle,
|
||||||
|
Zap, Shield, Tag, ArrowRight, ExternalLink, Copy,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/* ── Token icons ── */
|
||||||
|
const TOKEN_ICONS: Record<string, string> = {
|
||||||
|
ETH: '⟠',
|
||||||
|
MAOS: 'Ⓜ',
|
||||||
|
USDC: '⬡',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PaymentPanelProps {
|
||||||
|
fileSize: number; // bytes
|
||||||
|
fileName?: string;
|
||||||
|
onPaid: (paymentInfo: { cid: string; txHash?: string; method: 'free' | 'eth' | 'token' }) => void;
|
||||||
|
onUpload: () => Promise<{ cid: string; size: number }>;
|
||||||
|
contractAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymentPanel({
|
||||||
|
fileSize,
|
||||||
|
fileName,
|
||||||
|
onPaid,
|
||||||
|
onUpload,
|
||||||
|
contractAddress,
|
||||||
|
}: PaymentPanelProps) {
|
||||||
|
// Wallet state
|
||||||
|
const [address, setAddress] = useState<string | null>(null);
|
||||||
|
const [provider, setProvider] = useState<WalletProvider | null>(null);
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [walletType, setWalletType] = useState<string | null>(null);
|
||||||
|
const [wrongChain, setWrongChain] = useState(false);
|
||||||
|
|
||||||
|
// Contract state
|
||||||
|
const [deployed, setDeployed] = useState(false);
|
||||||
|
const [price, setPrice] = useState<PriceInfo | null>(null);
|
||||||
|
const [userStats, setUserStats] = useState<UserUploadStats | null>(null);
|
||||||
|
const [tokens, setTokens] = useState<TokenInfo[]>([]);
|
||||||
|
const [discountTiers, setDiscountTiers] = useState<MAOSDiscountTier[]>([]);
|
||||||
|
const [selectedToken, setSelectedToken] = useState('ETH');
|
||||||
|
const [freeTierBytes, setFreeTierBytes] = useState<bigint>(BigInt(0));
|
||||||
|
|
||||||
|
// Upload state
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [paying, setPaying] = useState(false);
|
||||||
|
const [status, setStatus] = useState<'idle' | 'paying' | 'uploading' | 'complete' | 'error'>('idle');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [txHash, setTxHash] = useState<string | null>(null);
|
||||||
|
const [uploadResult, setUploadResult] = useState<{ cid: string; size: number } | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Init
|
||||||
|
useEffect(() => {
|
||||||
|
if (contractAddress) {
|
||||||
|
paymentService.setContractAddress(contractAddress);
|
||||||
|
}
|
||||||
|
paymentService.isDeployed().then(setDeployed);
|
||||||
|
}, [contractAddress]);
|
||||||
|
|
||||||
|
// Refresh price + stats when address or fileSize changes
|
||||||
|
const refreshPricing = useCallback(async (addr: string, size: number) => {
|
||||||
|
if (!deployed) return;
|
||||||
|
try {
|
||||||
|
const [p, stats, t, tiers, freeBytes] = await Promise.all([
|
||||||
|
paymentService.getPrice(size, addr as `0x${string}`),
|
||||||
|
paymentService.getUserStats(addr as `0x${string}`),
|
||||||
|
paymentService.getSupportedTokens(),
|
||||||
|
paymentService.getMAOSDiscountTiers(),
|
||||||
|
paymentService.getFreeTierBytes(),
|
||||||
|
]);
|
||||||
|
setPrice(p);
|
||||||
|
setUserStats(stats);
|
||||||
|
setTokens(t.filter(tk => tk.enabled || tk.symbol === 'ETH'));
|
||||||
|
setDiscountTiers(tiers);
|
||||||
|
setFreeTierBytes(freeBytes);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Payment contract read failed:', e);
|
||||||
|
}
|
||||||
|
}, [deployed]);
|
||||||
|
|
||||||
|
// Connect wallet
|
||||||
|
async function handleConnect() {
|
||||||
|
setConnecting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await connectWallet();
|
||||||
|
const prov = getInjectedProvider();
|
||||||
|
setAddress(result.address);
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Check chain
|
||||||
|
if (result.chainId !== 270) {
|
||||||
|
setWrongChain(true);
|
||||||
|
const switched = await switchToChain270(prov || undefined);
|
||||||
|
if (switched) setWrongChain(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshPricing(result.address, fileSize);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Failed to connect wallet');
|
||||||
|
} finally {
|
||||||
|
setConnecting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh when fileSize changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (address && deployed) {
|
||||||
|
refreshPricing(address, fileSize);
|
||||||
|
}
|
||||||
|
}, [fileSize, address, deployed, refreshPricing]);
|
||||||
|
|
||||||
|
// Pay + Upload flow
|
||||||
|
async function handlePayAndUpload() {
|
||||||
|
if (!provider || !address) return;
|
||||||
|
setPaying(true);
|
||||||
|
setError(null);
|
||||||
|
setStatus('paying');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Upload to IPFS
|
||||||
|
setStatus('uploading');
|
||||||
|
const upload = await onUpload();
|
||||||
|
setUploadResult(upload);
|
||||||
|
|
||||||
|
// Step 2: Pay (if not free)
|
||||||
|
if (price && !price.isFree && price.finalWei > BigInt(0)) {
|
||||||
|
if (selectedToken === 'ETH') {
|
||||||
|
const hash = await paymentService.payWithETH(provider, upload.cid, fileSize, price.finalWei);
|
||||||
|
setTxHash(hash);
|
||||||
|
onPaid({ cid: upload.cid, txHash: hash, method: 'eth' });
|
||||||
|
} else {
|
||||||
|
// Token payment — need to find token address
|
||||||
|
const token = tokens.find(t => t.symbol === selectedToken);
|
||||||
|
if (!token || token.address === '0x0000000000000000000000000000000000000000') {
|
||||||
|
throw new Error(`${selectedToken} not configured on contract`);
|
||||||
|
}
|
||||||
|
// Calculate token amount
|
||||||
|
const tokenAmount = (price.finalWei * BigInt(10 ** token.decimals)) / token.weiPerToken;
|
||||||
|
const result = await paymentService.approveAndPayWithToken(
|
||||||
|
provider, token.address as `0x${string}`,
|
||||||
|
upload.cid, fileSize, tokenAmount, selectedToken
|
||||||
|
);
|
||||||
|
setTxHash(result.payHash);
|
||||||
|
onPaid({ cid: upload.cid, txHash: result.payHash, method: 'token' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Free upload
|
||||||
|
onPaid({ cid: upload.cid, method: 'free' });
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('complete');
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Payment/upload failed');
|
||||||
|
setStatus('error');
|
||||||
|
} finally {
|
||||||
|
setPaying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCID() {
|
||||||
|
if (!uploadResult) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(uploadResult.cid);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Render ── */
|
||||||
|
return (
|
||||||
|
<div className="glass rounded-xl p-5 space-y-5">
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wallet className="w-4 h-4 text-brand-400" />
|
||||||
|
<h3 className="text-sm font-semibold text-white">Crypto Payment</h3>
|
||||||
|
</div>
|
||||||
|
{address && walletType && (
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-700 text-surface-400">
|
||||||
|
{walletType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!deployed ? (
|
||||||
|
/* ── Not deployed ── */
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-800/50 text-surface-400 text-xs">
|
||||||
|
<Shield className="w-3.5 h-3.5" />
|
||||||
|
Payment contract not deployed — uploads are free
|
||||||
|
</div>
|
||||||
|
) : !address ? (
|
||||||
|
/* ── Connect wallet ── */
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={connecting}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{connecting ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin" /> Connecting...</>
|
||||||
|
) : (
|
||||||
|
<><Wallet className="w-4 h-4" /> Connect Wallet</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : wrongChain ? (
|
||||||
|
/* ── Wrong chain ── */
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs">
|
||||||
|
<XCircle className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
Please switch to zkSync Local (chain 270)
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ── Connected — show pricing ── */
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Wallet address */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
|
||||||
|
<span className="text-xs text-surface-400">Connected</span>
|
||||||
|
<span className="text-xs font-mono text-surface-200">
|
||||||
|
{address.slice(0, 6)}...{address.slice(-4)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Free tier info */}
|
||||||
|
{userStats && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="w-3.5 h-3.5 text-accent-cyan" />
|
||||||
|
<span className="text-xs text-surface-400">Free tier</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-surface-300">
|
||||||
|
{formatBytes(userStats.remainingFree)} / {formatBytes(freeTierBytes)} remaining
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Price info */}
|
||||||
|
{price && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
|
||||||
|
<span className="text-xs text-surface-400">File size</span>
|
||||||
|
<span className="text-xs text-surface-200">{formatBytes(fileSize)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{price.isFree ? (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-accent-green/10 border border-accent-green/20 text-accent-green text-xs">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
Free upload (within free tier)
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
|
||||||
|
<span className="text-xs text-surface-400">Price</span>
|
||||||
|
<span className="text-xs text-surface-200">{formatWeiToETH(price.totalWei, 6)} ETH</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{price.discountBps > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-accent-cyan/10 border border-accent-cyan/20">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag className="w-3 h-3 text-accent-cyan" />
|
||||||
|
<span className="text-xs text-accent-cyan">MAOS discount</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-accent-cyan font-medium">-{price.discountBps / 100}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-brand-500/10 border border-brand-500/20">
|
||||||
|
<span className="text-xs font-medium text-surface-300">Final</span>
|
||||||
|
<span className="text-sm font-bold text-brand-400">
|
||||||
|
{formatWeiToETH(price.finalWei, 6)} ETH
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token selector */}
|
||||||
|
{tokens.length > 1 && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-400 mb-1.5 block">Pay with</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{tokens.filter(t => t.enabled || t.symbol === 'ETH').map(token => (
|
||||||
|
<button
|
||||||
|
key={token.symbol}
|
||||||
|
onClick={() => setSelectedToken(token.symbol)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
selectedToken === token.symbol
|
||||||
|
? 'bg-brand-500/20 text-brand-400 border border-brand-500/30'
|
||||||
|
: 'bg-surface-800 text-surface-400 border border-surface-700 hover:border-surface-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{TOKEN_ICONS[token.symbol] || '●'}</span>
|
||||||
|
{token.symbol}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action button */}
|
||||||
|
<button
|
||||||
|
onClick={handlePayAndUpload}
|
||||||
|
disabled={paying || uploading || status === 'complete'}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{paying ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin" /> Processing payment...</>
|
||||||
|
) : uploading ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin" /> Uploading to IPFS...</>
|
||||||
|
) : status === 'complete' ? (
|
||||||
|
<><CheckCircle className="w-4 h-4" /> Complete</>
|
||||||
|
) : (
|
||||||
|
<><ArrowRight className="w-4 h-4" /> {price?.isFree ? 'Upload Free' : `Pay & Upload`}</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Result ── */}
|
||||||
|
{uploadResult && status === 'complete' && (
|
||||||
|
<div className="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 + Payment successful</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-surface-400">CID</span>
|
||||||
|
<button onClick={copyCID} className="flex items-center gap-1 text-surface-300 hover:text-surface-100 font-mono">
|
||||||
|
{uploadResult.cid.slice(0, 12)}...
|
||||||
|
{copied ? <CheckCircle className="w-3 h-3 text-accent-green" /> : <Copy className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{txHash && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-surface-400">Tx</span>
|
||||||
|
<a
|
||||||
|
href={`http://tom1687.no-ip.org:3050/tx/${txHash}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-brand-400 hover:text-brand-300 font-mono"
|
||||||
|
>
|
||||||
|
{txHash.slice(0, 10)}...
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Error ── */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-xs animate-fade-in">
|
||||||
|
<XCircle className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── MAOS Discount tiers ── */}
|
||||||
|
{address && discountTiers.length > 0 && (
|
||||||
|
<details className="text-xs text-surface-500">
|
||||||
|
<summary className="cursor-pointer hover:text-surface-400 transition-colors">
|
||||||
|
MAOS discount tiers
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{discountTiers.map((tier, i) => (
|
||||||
|
<div key={i} className="flex justify-between px-2 py-1 rounded bg-surface-800/30">
|
||||||
|
<span>{formatWeiToETH(tier.minBalance, 0)} MAOS</span>
|
||||||
|
<span className="text-accent-cyan">{tier.discountBps / 100}% off</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { checkHealth } from '@/lib/api';
|
||||||
|
import { Shield } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function PortalHeader() {
|
||||||
|
const [nodeStatus, setNodeStatus] = useState<'online' | 'offline' | 'checking'>('checking');
|
||||||
|
const [nodeVersion, setNodeVersion] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const h = await checkHealth();
|
||||||
|
if (!mounted) return;
|
||||||
|
setNodeStatus('online');
|
||||||
|
setNodeVersion(h.node);
|
||||||
|
} catch {
|
||||||
|
if (!mounted) return;
|
||||||
|
setNodeStatus('offline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
poll();
|
||||||
|
const iv = setInterval(poll, 30_000);
|
||||||
|
return () => { mounted = false; clearInterval(iv); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 h-14 flex items-center justify-between px-6 glass border-b border-surface-800">
|
||||||
|
{/* Page title slot — parent fills with children */}
|
||||||
|
<div className="flex items-center gap-3" />
|
||||||
|
|
||||||
|
{/* Right side */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Node status */}
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span
|
||||||
|
className={`status-dot status-dot--${nodeStatus === 'online' ? 'online' : 'offline'}`}
|
||||||
|
/>
|
||||||
|
<span className="text-surface-400">
|
||||||
|
{nodeStatus === 'online' ? `Node ${nodeVersion}` : 'Offline'}
|
||||||
|
</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">
|
||||||
|
<Shield className="w-3.5 h-3.5 text-accent-cyan" />
|
||||||
|
Wallet Connected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Upload,
|
||||||
|
Globe,
|
||||||
|
HardDrive,
|
||||||
|
Users,
|
||||||
|
Activity,
|
||||||
|
Settings,
|
||||||
|
Fingerprint,
|
||||||
|
Shield,
|
||||||
|
LogOut,
|
||||||
|
Clock,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ href: '/upload', label: 'Upload', icon: Upload },
|
||||||
|
{ href: '/explorer', label: 'Explorer', icon: Globe },
|
||||||
|
{ href: '/pins', label: 'Pins', icon: HardDrive },
|
||||||
|
{ href: '/history', label: 'History', icon: Clock },
|
||||||
|
{ href: '/ipns', label: 'IPNS', icon: Fingerprint },
|
||||||
|
{ href: '/peers', label: 'Peers', icon: Activity },
|
||||||
|
{ href: '/users', label: 'Users', icon: Users },
|
||||||
|
{ href: '/admin/payment', label: 'Payment Admin', icon: Shield },
|
||||||
|
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PortalSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* 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">
|
||||||
|
M
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-white">IPFS Portal</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 p-3 space-y-1 overflow-y-auto">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-150 ${
|
||||||
|
active
|
||||||
|
? 'bg-brand-500/10 text-brand-400 border border-brand-500/20'
|
||||||
|
: 'text-surface-400 hover:text-surface-200 hover:bg-surface-800/50 border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4 shrink-0" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Disconnect */}
|
||||||
|
<div className="p-3 border-t border-surface-800">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 shrink-0" />
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
/* ── IPFS Portal API Layer ── */
|
||||||
|
|
||||||
|
const API_BASE = ''; // nginx proxy /api/* → backend
|
||||||
|
|
||||||
|
export interface IPFSNodeInfo {
|
||||||
|
version: string;
|
||||||
|
peerId: string;
|
||||||
|
peers: number;
|
||||||
|
storageUsed: string;
|
||||||
|
storageMax: string;
|
||||||
|
uptime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPFSPin {
|
||||||
|
cid: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPFSUser {
|
||||||
|
username: string;
|
||||||
|
created: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Generic fetch wrapper ── */
|
||||||
|
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}/api${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
...options,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Node info ── */
|
||||||
|
export async function getNodeInfo(): Promise<IPFSNodeInfo> {
|
||||||
|
return apiFetch('/node/info');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPeers(): Promise<{ id: string; addr: string; latency: string }[]> {
|
||||||
|
const raw = await apiFetch<{ Peers?: { Peer: string; Addr: string; Latency: string }[] }>('/peers');
|
||||||
|
return (raw.Peers || []).map((p: { Peer: string; Addr: string; Latency: string }) => ({
|
||||||
|
id: p.Peer,
|
||||||
|
addr: p.Addr,
|
||||||
|
latency: p.Latency || '—',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Storage ── */
|
||||||
|
export async function listPins(): Promise<IPFSPin[]> {
|
||||||
|
const raw = await apiFetch<{ Keys?: Record<string, { Type: string }> }>('/pins');
|
||||||
|
if (!raw.Keys) return [];
|
||||||
|
return Object.entries(raw.Keys).map(([cid, info]) => ({
|
||||||
|
cid,
|
||||||
|
name: '',
|
||||||
|
size: 0,
|
||||||
|
created: info.Type || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addPin(cid: string, name?: string): Promise<{ cid: string }> {
|
||||||
|
return apiFetch('/pins', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ cid, name }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePin(cid: string): Promise<void> {
|
||||||
|
await apiFetch(`/pins/${cid}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Files ── */
|
||||||
|
export async function uploadFile(file: File): Promise<{ cid: string; size: number }> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const res = await fetch(`${API_BASE}/api/files/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Upload failed: ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFiles(): Promise<{ name: string; cid: string; size: number; modified: string }[]> {
|
||||||
|
return apiFetch('/files');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Users (admin) ── */
|
||||||
|
export async function listUsers(): Promise<IPFSUser[]> {
|
||||||
|
const raw = await apiFetch<IPFSUser[] | { users: IPFSUser[] }>('/users');
|
||||||
|
// Handle both [{...}] and {users: [{...}]} response formats
|
||||||
|
if (Array.isArray(raw)) return raw;
|
||||||
|
if (raw && Array.isArray(raw.users)) return raw.users;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(username: string, password: string): Promise<{ username: string }> {
|
||||||
|
return apiFetch('/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(username: string): Promise<void> {
|
||||||
|
await apiFetch(`/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── IPFS Explorer ── */
|
||||||
|
|
||||||
|
export interface IPFSEntry {
|
||||||
|
name: string;
|
||||||
|
type: 'file' | 'dir';
|
||||||
|
size: number;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function explorerLs(cid: string): Promise<IPFSEntry[]> {
|
||||||
|
return apiFetch(`/explorer/ls/${encodeURIComponent(cid)}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function explorerCat(cid: string): Promise<string> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/explorer/cat/${encodeURIComponent(cid)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function explorerStat(cid: string): Promise<Record<string, unknown>> {
|
||||||
|
return apiFetch(`/explorer/stat/${encodeURIComponent(cid)}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── IPNS ── */
|
||||||
|
|
||||||
|
export interface IPNSKey {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listIPNSKeys(): Promise<IPNSKey[]> {
|
||||||
|
const res = await apiFetch<{ Keys: IPNSKey[] }>('/ipns/keys');
|
||||||
|
return res.Keys || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ipnsPublish(cid: string, key = 'self'): Promise<{ name: string; value: string }> {
|
||||||
|
return apiFetch(`/ipns/publish?arg=${encodeURIComponent(cid)}&key=${encodeURIComponent(key)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ipnsResolve(name: string): Promise<string> {
|
||||||
|
const res = await apiFetch<{ Path: string }>(`/ipns/resolve/${encodeURIComponent(name)}`);
|
||||||
|
return res.Path || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ipnsGenKey(name: string): Promise<IPNSKey> {
|
||||||
|
return apiFetch(`/ipns/keys/gen/${encodeURIComponent(name)}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Repo / Bandwidth Stats ── */
|
||||||
|
|
||||||
|
export interface RepoStats {
|
||||||
|
repoSize: number;
|
||||||
|
storageMax: number;
|
||||||
|
numObjects: number;
|
||||||
|
repoPath: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BWStats {
|
||||||
|
totalIn: number;
|
||||||
|
totalOut: number;
|
||||||
|
rateIn: number;
|
||||||
|
rateOut: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRepoStats(): Promise<RepoStats> {
|
||||||
|
const raw = await apiFetch<{
|
||||||
|
RepoSize?: number; StorageMax?: number; NumObjects?: number;
|
||||||
|
RepoPath?: string; Version?: string;
|
||||||
|
}>('/repo/stats');
|
||||||
|
return {
|
||||||
|
repoSize: raw.RepoSize ?? 0,
|
||||||
|
storageMax: raw.StorageMax ?? 0,
|
||||||
|
numObjects: raw.NumObjects ?? 0,
|
||||||
|
repoPath: raw.RepoPath ?? '',
|
||||||
|
version: raw.Version ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBWStats(): Promise<BWStats> {
|
||||||
|
const raw = await apiFetch<{
|
||||||
|
TotalIn?: number; TotalOut?: number; RateIn?: number; RateOut?: number;
|
||||||
|
}>('/bw/stats');
|
||||||
|
return {
|
||||||
|
totalIn: raw.TotalIn ?? 0,
|
||||||
|
totalOut: raw.TotalOut ?? 0,
|
||||||
|
rateIn: raw.RateIn ?? 0,
|
||||||
|
rateOut: raw.RateOut ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Health ── */
|
||||||
|
export async function checkHealth(): Promise<{ status: string; node: string }> {
|
||||||
|
return apiFetch('/health');
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
/* ── 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,98 @@
|
|||||||
|
/* ── Client-side storage for IPFS Portal ──
|
||||||
|
*
|
||||||
|
* PortalSettings — editable config (gateway, API, storage max, refresh, theme)
|
||||||
|
* UploadRecord — persisted upload history (auto-saved after each upload)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ════════════════════════════ Types ════════════════════════════ */
|
||||||
|
|
||||||
|
export interface PortalSettings {
|
||||||
|
gatewayUrl: string;
|
||||||
|
apiEndpoint: string;
|
||||||
|
storageMax: number; // MB
|
||||||
|
refreshInterval: number; // seconds (dashboard auto-refresh)
|
||||||
|
theme: 'dark' | 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadRecord {
|
||||||
|
cid: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
date: string; // ISO string
|
||||||
|
method: 'free' | 'eth' | 'token' | 'quick';
|
||||||
|
txHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════ Defaults ════════════════════════════ */
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ipfs-portal-settings';
|
||||||
|
const HISTORY_KEY = 'ipfs-portal-history';
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: PortalSettings = {
|
||||||
|
gatewayUrl: 'https://maos.dedyn.io/ipfs',
|
||||||
|
apiEndpoint: '',
|
||||||
|
storageMax: 30720, // 30 GB
|
||||||
|
refreshInterval: 15,
|
||||||
|
theme: 'dark',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ════════════════════════════ Settings ════════════════════════════ */
|
||||||
|
|
||||||
|
export function getSettings(): PortalSettings {
|
||||||
|
if (typeof window === 'undefined') return { ...DEFAULT_SETTINGS };
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_SETTINGS };
|
||||||
|
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings(partial: Partial<PortalSettings>): PortalSettings {
|
||||||
|
const current = getSettings();
|
||||||
|
const merged = { ...current, ...partial };
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
|
||||||
|
} catch { /* quota exceeded — silently degrade */ }
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSettings(): PortalSettings {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════ History ════════════════════════════ */
|
||||||
|
|
||||||
|
export function getHistory(): UploadRecord[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(HISTORY_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
return JSON.parse(raw) as UploadRecord[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToHistory(record: UploadRecord): UploadRecord[] {
|
||||||
|
const history = getHistory();
|
||||||
|
// Deduplicate by CID + name
|
||||||
|
const filtered = history.filter(h => !(h.cid === record.cid && h.name === record.name));
|
||||||
|
filtered.unshift(record);
|
||||||
|
// Keep max 500 entries
|
||||||
|
const trimmed = filtered.slice(0, 500);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHistory(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(HISTORY_KEY);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/* ── Wallet connection (EIP-6963 + MAOS extension) ── */
|
||||||
|
|
||||||
|
/* ── Types ── */
|
||||||
|
export interface WalletProvider {
|
||||||
|
request: (args: { method: string; params?: unknown[] }) => Promise<any>;
|
||||||
|
on?: (event: string, cb: (...args: any[]) => void) => void;
|
||||||
|
removeListener?: (event: string, cb: (...args: any[]) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletState {
|
||||||
|
address: string;
|
||||||
|
chainId: number;
|
||||||
|
isConnected: boolean;
|
||||||
|
isConnecting: boolean;
|
||||||
|
walletType: 'metamask' | 'maos' | 'rabby' | 'other' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectedWallet {
|
||||||
|
name: string;
|
||||||
|
provider: WalletProvider;
|
||||||
|
icon?: string;
|
||||||
|
isMAOS: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chain IDs ── */
|
||||||
|
export const CHAIN_IDS = {
|
||||||
|
ZKSYNC_LOCAL: 270,
|
||||||
|
ZKSYNC_ERA: 324,
|
||||||
|
ETHEREUM: 1,
|
||||||
|
SEPOLIA: 11155111,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/* ── Chain 270 params for wallet_addEthereumChain ── */
|
||||||
|
const ZKSYNC_LOCAL_CHAIN = {
|
||||||
|
chainId: '0x10E', // 270
|
||||||
|
chainName: 'zkSync Local',
|
||||||
|
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||||
|
rpcUrls: ['http://tom1687.no-ip.org:3050'],
|
||||||
|
blockExplorerUrls: ['http://tom1687.no-ip.org:3050'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── EIP-6963 event-based provider discovery ── */
|
||||||
|
export function discoverWallets(): Promise<DetectedWallet[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const wallets: DetectedWallet[] = [];
|
||||||
|
const timeout = setTimeout(() => resolve(wallets), 2000);
|
||||||
|
|
||||||
|
function handleAnnounce(e: CustomEvent) {
|
||||||
|
const detail = e.detail;
|
||||||
|
if (detail?.provider) {
|
||||||
|
const info = detail.info || {};
|
||||||
|
const isMAOS = !!(info.name?.toLowerCase().includes('maos') || detail.provider.isMAOSv6);
|
||||||
|
// Avoid duplicates
|
||||||
|
if (!wallets.some(w => w.provider === detail.provider)) {
|
||||||
|
wallets.push({
|
||||||
|
name: info.name || (isMAOS ? 'MAOS Wallet' : 'Unknown Wallet'),
|
||||||
|
provider: detail.provider,
|
||||||
|
icon: info.icon,
|
||||||
|
isMAOS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('eip6963:announceProvider', handleAnnounce as EventListener);
|
||||||
|
|
||||||
|
// Request announcement
|
||||||
|
window.dispatchEvent(new CustomEvent('eip6963:requestProvider'));
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
provider: legacy,
|
||||||
|
isMAOS: !!isMAOS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(wallets);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Connect wallet ── */
|
||||||
|
export async function connectWallet(provider?: WalletProvider): Promise<{ address: string; chainId: number }> {
|
||||||
|
const p = provider || getInjectedProvider();
|
||||||
|
if (!p) throw new Error('No wallet found. Install MetaMask, Rabby, or MAOS extension.');
|
||||||
|
|
||||||
|
const accounts: string[] = await p.request({ method: 'eth_requestAccounts' });
|
||||||
|
const chainIdHex: string = await p.request({ method: 'eth_chainId' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
address: accounts[0],
|
||||||
|
chainId: parseInt(chainIdHex, 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Switch to chain 270 (zkSync Local) ── */
|
||||||
|
export async function switchToChain270(provider?: WalletProvider): Promise<boolean> {
|
||||||
|
const p = provider || getInjectedProvider();
|
||||||
|
if (!p) throw new Error('No wallet found');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await p.request({
|
||||||
|
method: 'wallet_switchEthereumChain',
|
||||||
|
params: [{ chainId: '0x10E' }],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
// 4902 = chain not added yet
|
||||||
|
if (e.code === 4902) {
|
||||||
|
try {
|
||||||
|
await p.request({
|
||||||
|
method: 'wallet_addEthereumChain',
|
||||||
|
params: [ZKSYNC_LOCAL_CHAIN],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sign message ── */
|
||||||
|
export async function signMessage(
|
||||||
|
provider: WalletProvider,
|
||||||
|
message: string,
|
||||||
|
address: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return provider.request({
|
||||||
|
method: 'personal_sign',
|
||||||
|
params: [message, address],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Get ETH balance ── */
|
||||||
|
export async function getBalance(address: string): Promise<string> {
|
||||||
|
if (typeof window === 'undefined') return '0';
|
||||||
|
const provider = getInjectedProvider();
|
||||||
|
if (!provider) return '0';
|
||||||
|
|
||||||
|
const balanceHex: string = await provider.request({
|
||||||
|
method: 'eth_getBalance',
|
||||||
|
params: [address, 'latest'],
|
||||||
|
});
|
||||||
|
return BigInt(balanceHex).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Format wei to ETH string ── */
|
||||||
|
export function formatWeiToETH(wei: bigint | string, decimals = 4): string {
|
||||||
|
const value = typeof wei === 'string' ? BigInt(wei) : wei;
|
||||||
|
const eth = Number(value) / 1e18;
|
||||||
|
return eth.toFixed(decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Format bytes to human-readable ── */
|
||||||
|
export function formatBytes(bytes: bigint | number): string {
|
||||||
|
const b = typeof bytes === 'bigint' ? Number(bytes) : bytes;
|
||||||
|
if (b < 1024) return `${b} B`;
|
||||||
|
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||||
|
if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(b / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('IPFS Portal', () => {
|
||||||
|
const BASE = 'http://localhost:3445';
|
||||||
|
|
||||||
|
test('health endpoint returns 200', async ({ request }) => {
|
||||||
|
const r = await request.get(`${BASE}/api/health`);
|
||||||
|
expect(r.ok()).toBeTruthy();
|
||||||
|
const body = await r.json();
|
||||||
|
expect(body).toHaveProperty('status', 'ok');
|
||||||
|
expect(body).toHaveProperty('kuboApi');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard loads without errors', async ({ page }) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); });
|
||||||
|
page.on('pageerror', (err) => errors.push('PAGE: ' + err.message));
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/dashboard`, { waitUntil: 'networkidle' });
|
||||||
|
await expect(page.locator('h1')).toContainText('Dashboard');
|
||||||
|
|
||||||
|
const critical = errors.filter(
|
||||||
|
(e) => !e.includes('favicon') && !e.includes('DevTools') && !e.includes('404'),
|
||||||
|
);
|
||||||
|
expect(critical).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explorer page renders', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE}/explorer`, { waitUntil: 'networkidle' });
|
||||||
|
await expect(page.locator('h1')).toContainText(/Explorer/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('peers page loads', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE}/peers`, { waitUntil: 'networkidle' });
|
||||||
|
await expect(page.locator('h1')).toContainText(/Peers/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pins page loads', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE}/pins`, { waitUntil: 'networkidle' });
|
||||||
|
await expect(page.locator('h1')).toContainText(/Pins/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upload page renders with tabs and drop zone', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE}/upload`, { waitUntil: 'networkidle' });
|
||||||
|
await expect(page.locator('h1')).toContainText(/Upload/i);
|
||||||
|
// Quick Upload tab should show drop zone with the highlighted "Click to browse" text
|
||||||
|
await expect(page.getByText('Click to browse', { exact: true })).toBeVisible();
|
||||||
|
// Tab switcher should exist
|
||||||
|
await expect(page.getByRole('button', { name: /Quick Upload/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /Crypto Payment/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upload page crypto tab switches correctly', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE}/upload`, { waitUntil: 'networkidle' });
|
||||||
|
// Switch to Crypto tab
|
||||||
|
await page.getByRole('button', { name: 'Crypto Payment' }).click();
|
||||||
|
await expect(page.locator('text=Select a file to see pricing')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IPNS page loads keys', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE}/ipns`, { waitUntil: 'networkidle' });
|
||||||
|
await expect(page.locator('h1')).toContainText(/IPNS/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history page loads with empty state', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE}/history`, { waitUntil: 'networkidle' });
|
||||||
|
await expect(page.locator('h1')).toContainText(/Upload History/i);
|
||||||
|
await expect(page.locator('text=No upload history yet')).toBeVisible();
|
||||||
|
await expect(page.locator('text=Upload your first file')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings page loads with editable form', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE}/settings`, { waitUntil: 'networkidle' });
|
||||||
|
await expect(page.locator('h1')).toContainText('Settings');
|
||||||
|
// Should show input fields
|
||||||
|
const inputs = page.locator('input');
|
||||||
|
const count = await inputs.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(3);
|
||||||
|
// Save and Reset buttons
|
||||||
|
await expect(page.locator('button:has-text("Save Settings")')).toBeVisible();
|
||||||
|
await expect(page.locator('button:has-text("Reset Defaults")')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IPFS node info via proxy', async ({ request }) => {
|
||||||
|
const r = await request.get(`${BASE}/api/node/info`);
|
||||||
|
expect(r.ok()).toBeTruthy();
|
||||||
|
const body = await r.json();
|
||||||
|
expect(body).toHaveProperty('ID');
|
||||||
|
expect(body).toHaveProperty('AgentVersion');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('swarm peers endpoint', async ({ request }) => {
|
||||||
|
const r = await request.get(`${BASE}/api/peers`);
|
||||||
|
expect(r.ok()).toBeTruthy();
|
||||||
|
const body = await r.json();
|
||||||
|
expect(body).toHaveProperty('Peers');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repo stats endpoint', async ({ request }) => {
|
||||||
|
const r = await request.get(`${BASE}/api/repo/stats`);
|
||||||
|
expect(r.ok()).toBeTruthy();
|
||||||
|
const body = await r.json();
|
||||||
|
expect(body).toHaveProperty('RepoSize');
|
||||||
|
expect(body).toHaveProperty('NumObjects');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar navigation links exist and work', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE}/dashboard`, { waitUntil: 'networkidle' });
|
||||||
|
await expect(page.locator('h1')).toContainText('Dashboard');
|
||||||
|
|
||||||
|
const sidebar = page.locator('aside');
|
||||||
|
await expect(sidebar).toBeVisible();
|
||||||
|
const links = sidebar.getByRole('link');
|
||||||
|
const count = await links.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(8); // History added
|
||||||
|
|
||||||
|
for (const name of ['Explorer', 'Pins', 'Peers', 'History']) {
|
||||||
|
await sidebar.getByRole('link', { name }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(name.toLowerCase()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Payment Flow ── */
|
||||||
|
|
||||||
|
test('upload page crypto tab shows payment panel after file selection', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE}/upload`, { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
// Switch to Crypto tab
|
||||||
|
await page.getByRole('button', { name: 'Crypto Payment' }).click();
|
||||||
|
|
||||||
|
// Create a dummy file and set it on the crypto file input
|
||||||
|
const fileInput = page.locator('input[type="file"]').last();
|
||||||
|
await fileInput.setInputFiles({
|
||||||
|
name: 'test.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
buffer: Buffer.from('hello ipfs payment test'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Payment panel should now be visible with "Crypto Payment" heading
|
||||||
|
await expect(page.getByRole('heading', { name: 'Crypto Payment' })).toBeVisible();
|
||||||
|
|
||||||
|
// Should either show "not deployed" or "Connect Wallet"
|
||||||
|
const notDeployed = page.locator('text=Payment contract not deployed');
|
||||||
|
const connectWallet = page.locator('button:has-text("Connect Wallet")');
|
||||||
|
|
||||||
|
const isNotDeployed = await notDeployed.isVisible().catch(() => false);
|
||||||
|
if (isNotDeployed) {
|
||||||
|
await expect(notDeployed).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(connectWallet).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('payment verification API accepts address query', async ({ request }) => {
|
||||||
|
// Test with a zero address to verify the API structure works
|
||||||
|
const r = await request.get(`${BASE}/api/payment/verify?address=0x0000000000000000000000000000000000000000`);
|
||||||
|
expect(r.ok()).toBeTruthy();
|
||||||
|
const body = await r.json();
|
||||||
|
|
||||||
|
// Should either show deployed or not
|
||||||
|
expect(body).toHaveProperty('deployed');
|
||||||
|
if (body.deployed) {
|
||||||
|
expect(body).toHaveProperty('stats');
|
||||||
|
expect(body.stats).toHaveProperty('totalBytes');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable",
|
||||||
|
"ESNext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"out/types/**/*.ts",
|
||||||
|
"out/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"out"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user