commit 1ddc89479cc4576fc39efbb5f98578d61d736474
Author: maikrolf <40924921+maikrolf@users.noreply.github.com>
Date: Thu Jun 25 19:47:57 2026 +0200
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.
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..8578669
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+node_modules
+.git
+.gitignore
+*.md
+.env
+.env.local
+.next
+out
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a83c529
--- /dev/null
+++ b/.gitignore
@@ -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
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..9d00229
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/deploy.sh b/deploy.sh
new file mode 100644
index 0000000..30bb368
--- /dev/null
+++ b/deploy.sh
@@ -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"
diff --git a/next.config.ts b/next.config.ts
new file mode 100644
index 0000000..bf10ce4
--- /dev/null
+++ b/next.config.ts
@@ -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;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..e881d0f
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1913 @@
+{
+ "name": "@maos/ipfs-portal",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@maos/ipfs-portal",
+ "version": "1.0.0",
+ "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"
+ }
+ },
+ "node_modules/@adraffy/ens-normalize": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz",
+ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==",
+ "license": "MIT"
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "16.2.9",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz",
+ "integrity": "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "16.2.9",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz",
+ "integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "16.2.9",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz",
+ "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.2.9",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz",
+ "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.2.9",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz",
+ "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.2.9",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz",
+ "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.2.9",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz",
+ "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.2.9",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz",
+ "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "16.2.9",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz",
+ "integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@noble/ciphers": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
+ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
+ "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "1.8.0"
+ },
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
+ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@scure/base": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
+ "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@scure/bip32": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz",
+ "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "~1.9.0",
+ "@noble/hashes": "~1.8.0",
+ "@scure/base": "~1.2.5"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@scure/bip39": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
+ "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "~1.8.0",
+ "@scure/base": "~1.2.5"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
+ "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "5.21.6",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz",
+ "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.1",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.1",
+ "@tailwindcss/oxide-darwin-x64": "4.3.1",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.1",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.1",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.1",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.1",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.1",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz",
+ "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz",
+ "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz",
+ "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz",
+ "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz",
+ "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz",
+ "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz",
+ "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz",
+ "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz",
+ "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz",
+ "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.10.0",
+ "@emnapi/runtime": "^1.10.0",
+ "@emnapi/wasi-threads": "^1.2.1",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz",
+ "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz",
+ "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.1.tgz",
+ "integrity": "sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==",
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.3.1",
+ "@tailwindcss/oxide": "4.3.1",
+ "postcss": "8.5.15",
+ "tailwindcss": "4.3.1"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
+ "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/abitype": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz",
+ "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/wevm"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.4",
+ "zod": "^3.22.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ },
+ "zod": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.38",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
+ "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001799",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
+ "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.21.6",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
+ "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/isows": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
+ "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/wevm"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "ws": "*"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.577.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
+ "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "16.2.9",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz",
+ "integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.2.9",
+ "@swc/helpers": "0.5.15",
+ "baseline-browser-mapping": "^2.9.19",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "16.2.9",
+ "@next/swc-darwin-x64": "16.2.9",
+ "@next/swc-linux-arm64-gnu": "16.2.9",
+ "@next/swc-linux-arm64-musl": "16.2.9",
+ "@next/swc-linux-x64-gnu": "16.2.9",
+ "@next/swc-linux-x64-musl": "16.2.9",
+ "@next/swc-win32-arm64-msvc": "16.2.9",
+ "@next/swc-win32-x64-msvc": "16.2.9",
+ "sharp": "^0.34.5"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/ox": {
+ "version": "0.14.29",
+ "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.29.tgz",
+ "integrity": "sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/wevm"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@adraffy/ens-normalize": "^1.11.0",
+ "@noble/ciphers": "^1.3.0",
+ "@noble/curves": "1.9.1",
+ "@noble/hashes": "^1.8.0",
+ "@scure/bip32": "^1.7.0",
+ "@scure/bip39": "^1.6.0",
+ "abitype": "^1.2.3",
+ "eventemitter3": "5.0.1"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/playwright": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
+ "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/viem": {
+ "version": "2.53.1",
+ "resolved": "https://registry.npmjs.org/viem/-/viem-2.53.1.tgz",
+ "integrity": "sha512-FhfJ/SW73CVosiyVLmIMVgKDRKYV1AGCLzZoHYvmNayyVff63Qi1ocPCk59LqC/cNw244RbBJjHnmxqXkE7NpA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/wevm"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "1.9.1",
+ "@noble/hashes": "1.8.0",
+ "@scure/bip32": "1.7.0",
+ "@scure/bip39": "1.6.0",
+ "abitype": "1.2.3",
+ "isows": "1.0.7",
+ "ox": "0.14.29",
+ "ws": "8.20.1"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.4"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.20.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
+ "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..fc3f66e
--- /dev/null
+++ b/package.json
@@ -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"
+ }
+}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..842ca1f
--- /dev/null
+++ b/playwright.config.ts
@@ -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'] },
+ },
+ ],
+});
diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000..a34a3d5
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+};
diff --git a/serve-static.js b/serve-static.js
new file mode 100644
index 0000000..97c0c17
--- /dev/null
+++ b/serve-static.js
@@ -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:);
+});
diff --git a/src/app/admin/payment/page.tsx b/src/app/admin/payment/page.tsx
new file mode 100644
index 0000000..b26d0eb
--- /dev/null
+++ b/src/app/admin/payment/page.tsx
@@ -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(null);
+ const [provider, setProvider] = useState(null);
+ const [connecting, setConnecting] = useState(false);
+ const [wrongChain, setWrongChain] = useState(false);
+ const [walletType, setWalletType] = useState(null);
+ const [view, setView] = useState('overview');
+ const [txStatus, setTxStatus] = useState(null);
+
+ // Contract state
+ const [owner, setOwner] = useState(null);
+ const [deployed, setDeployed] = useState(false);
+ const [basePrice, setBasePrice] = useState(BigInt(0));
+ const [freeTier, setFreeTier] = useState(BigInt(0));
+ const [tokens, setTokens] = useState([]);
+ const [tiers, setTiers] = useState([]);
+ const [totalUploads, setTotalUploads] = useState(BigInt(0));
+ const [revenues, setRevenues] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [contractBalance, setContractBalance] = useState>({});
+
+ 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();
+ 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 = {};
+ symbols.forEach((s, i) => { revMap[s] = revEntries[i]; });
+ setRevenues(revMap);
+
+ // Balances via RPC
+ const balMap: Record = {};
+ 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) {
+ 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(-1);
+ const [tokenSymbol, setTokenSymbol] = useState('');
+ const [tokenAddr, setTokenAddr] = useState('');
+ const [tokenDec, setTokenDec] = useState('18');
+ const [tokenWei, setTokenWei] = useState('');
+ const [tokenEnabled, setTokenEnabled] = useState(true);
+
+ return (
+
+ {/* ── Header ── */}
+
+
+
Payment Admin
+
Manage IPFS Portal payment contract
+
+
+ {address && (
+
+
+ {isOwner ? 'Owner' : 'Viewer'}
+
+ )}
+
+
+
+
+ {/* ── Connect wallet ── */}
+ {!address && (
+
+
+
Connect wallet to view admin panel
+
+
+ )}
+
+ {wrongChain && (
+
+
+ Please switch to zkSync Local (chain 270)
+
+ )}
+
+ {address && (
+ <>
+ {/* ── Nav tabs ── */}
+
+ {(['overview', 'pricing', 'tokens', 'tiers'] as ViewMode[]).map(v => (
+
+ ))}
+
+
+ {/* ── Loading ── */}
+ {loading ? (
+
+ Loading contract state...
+
+ ) : (
+ <>
+ {/* ── Overview ── */}
+ {view === 'overview' && (
+
+ {/* Stats grid */}
+
+
+
Total Uploads
+
{totalUploads.toString()}
+
+ {tokens.filter(t => t.enabled).map(tk => {
+ const rev = revenues[tk.symbol] || BigInt(0);
+ const bal = contractBalance[tk.symbol];
+ return (
+
+
{tk.symbol} Revenue
+
+ {tk.symbol === 'ETH' ? formatWeiToETH(rev, 6) : rev.toString()}
+
+ {bal && (
+
+ Balance: {tk.symbol === 'ETH' ? formatWeiToETH(bal, 6) : bal} {tk.symbol}
+
+ )}
+
+ );
+ })}
+
+
+ {/* Contract info */}
+
+
Contract
+
+
+ Address
+
+ {paymentService['contractAddress'].slice(0, 10)}...
+
+
+
+ Owner
+
+ {owner ? `${owner.slice(0, 8)}...${owner.slice(-6)}` : '-'}
+ {isOwner && ✓}
+
+
+
+ Deployed
+
+ {deployed ? 'Yes' : 'No'}
+
+
+
+ Connected
+
+ {address.slice(0, 6)}...{address.slice(-4)}
+
+
+
+
+
+ {/* If owner — quick actions */}
+ {isOwner && (
+
+
Quick Actions
+
+
+
+
+
+
+ )}
+
+ )}
+
+ {/* ── Pricing ── */}
+ {view === 'pricing' && (
+
+
+
+ Pricing Config
+
+
+ {/* Current values */}
+
+
+
Base Price / MB
+
{formatWeiToETH(basePrice, 8)} ETH
+
+
+
Free Tier
+
{formatBytes(freeTier)}
+
+
+
+ {isOwner && (
+ <>
+ {/* Set price */}
+
+
+
+ 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"
+ />
+
+
+
+ Current: {formatWeiToETH(basePrice, 8)} ETH — e.g. 100000000000000 = 0.0001 ETH
+
+
+
+ {/* Set free tier */}
+
+
+
+ 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"
+ />
+
+
+
+ Current: {formatBytes(freeTier)} — 10485760 = 10 MB
+
+
+
+ {/* Withdraw */}
+
+
+
+ {tokens.filter(t => t.enabled).map(tk => {
+ const bal = contractBalance[tk.symbol];
+ if (!bal || bal === '0') return null;
+ return (
+
+ );
+ })}
+
+
+ >
+ )}
+
+
+ )}
+
+ {/* ── Tokens ── */}
+ {view === 'tokens' && (
+
+
+
+ Supported Tokens
+
+
+ {/* Token list */}
+
+ {tokens.map(tk => (
+
+
+
+ {tk.symbol}
+ {tk.address.slice(0, 10)}...
+ {tk.decimals} decimals
+
+
{formatWeiToETH(tk.weiPerToken, 4)} wei/token
+
+ ))}
+
+
+
+ {/* Admin: configure token */}
+ {isOwner && (
+
+ )}
+
+ {/* Withdraw section */}
+ {isOwner && Object.keys(contractBalance).length > 0 && (
+
+
+ Withdraw
+
+
+ {Object.entries(contractBalance).filter(([_, bal]) => bal !== '0').map(([sym, bal]) => (
+
+ ))}
+ {Object.values(contractBalance).every(b => b === '0') && (
+ No balance to withdraw
+ )}
+
+
+ )}
+
+ )}
+
+ {/* ── Discount Tiers ── */}
+ {view === 'tiers' && (
+
+
+
+ MAOS Discount Tiers
+
+
+ {/* Tier list */}
+
+ {tiers.map((tier, i) => (
+
+
+ {formatWeiToETH(tier.minBalance, 0)} MAOS
+ {tier.discountBps / 100}% off
+
+ {isOwner && (
+
+ )}
+
+ ))}
+
+
+
+ {/* Admin: add tier */}
+ {isOwner && (
+
+
+ Add / Update Tier
+
+
+
+
+ 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"
+ />
+
+
+
+ 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"
+ />
+
+
+
+
+ )}
+
+ )}
+ >
+ )}
+
+ {/* ── TX Status ── */}
+ {txStatus && (
+
+ {txStatus.ok ? : }
+ {txStatus.msg}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/app/api/[...path]/route.ts b/src/app/api/[...path]/route.ts
new file mode 100644
index 0000000..cf89ba9
--- /dev/null
+++ b/src/app/api/[...path]/route.ts
@@ -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/ → 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 | null): Promise {
+ const url = `${USER_API_BASE}${path}`;
+ const headers: Record = {
+ '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 {
+ // 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/ → /api/v0/ls?arg=, 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 = {};
+ 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/, /explorer/cat/, 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 {
+ // 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 = {};
+ 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 {
+ const text = await res.text();
+ return new NextResponse(text, {
+ status: res.status,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+}
diff --git a/src/app/api/payment/verify/route.ts b/src/app/api/payment/verify/route.ts
new file mode 100644
index 0000000..26e21c2
--- /dev/null
+++ b/src/app/api/payment/verify/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
new file mode 100644
index 0000000..44f0826
--- /dev/null
+++ b/src/app/dashboard/page.tsx
@@ -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([]);
+ const [pins, setPins] = useState([]);
+ const [health, setHealth] = useState<{ status: string; node: string } | null>(null);
+ const [repo, setRepo] = useState(null);
+ const [bw, setBW] = useState(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 (
+
+ {/* Page header */}
+
+
+
Dashboard
+
IPFS node overview, storage, and bandwidth
+
+ {loading && (
+
+ )}
+
+
+ {/* Stat cards */}
+
+ {stats.map((s) => (
+
+
+
{s.value}
+
{s.label}
+
+ ))}
+
+
+ {/* Row 2: Peer + Bandwidth */}
+
+ {/* Peers list */}
+
+
+ Connected Peers
+ {peers.length} peer{peers.length !== 1 ? 's' : ''}
+
+ {peers.length === 0 ? (
+
No peers connected
+ ) : (
+
+ {peers.slice(0, 20).map((p) => (
+
+
+ {p.id.slice(0, 20)}…
+
+
+ {p.latency}
+
+
+
+ ))}
+
+ )}
+
+
+ View all peers →
+
+
+
+
+ {/* Bandwidth */}
+
+
+
+ Bandwidth
+
+
+
+
+
Total since node start
+
+
+
+ {/* IPNS quick */}
+
+
+
+ IPNS Names
+
+
+ Publish CIDs to human-readable IPNS names, or resolve existing names.
+
+
+
+
Manage IPNS Keys
+
+
+
+
Browse Explorer
+
+
+
+
+
+
+ {/* Row 3: Pins + Repo details */}
+
+ {/* Pins */}
+
+
+ Recent Pins
+ {pins.length} total
+
+ {pins.length === 0 ? (
+
No pins yet — upload or add content to get started.
+ ) : (
+
+ {pins.slice(0, 8).map((pin) => (
+
+
+ {pin.name || pin.cid.slice(0, 24) + '…'}
+
+
+ {(pin.size / 1024).toFixed(1)} KB
+
+
+ ))}
+
+ )}
+
+
+ Manage pins →
+
+
+ Upload new →
+
+
+
+
+ {/* Repo Details */}
+
+
+
+ Repository
+
+ {repo ? (
+
+
+ Size
+ {repoSizeGb} GB
+
+
+ Objects
+ {repo.numObjects.toLocaleString()}
+
+
+ Storage Max
+ {(repo.storageMax / 1e9).toFixed(1)} GB
+
+
+ Version
+ {repo.version || health?.node || '—'}
+
+
+ Path
+ {repo.repoPath}
+
+
+ ) : (
+
Repo stats unavailable (Kubo may not expose /api/v0/repo/stat via gateway).
+ )}
+
+
+
+ );
+}
diff --git a/src/app/explorer/components/Breadcrumbs.tsx b/src/app/explorer/components/Breadcrumbs.tsx
new file mode 100644
index 0000000..c99119e
--- /dev/null
+++ b/src/app/explorer/components/Breadcrumbs.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/app/explorer/components/CIDInput.tsx b/src/app/explorer/components/CIDInput.tsx
new file mode 100644
index 0000000..5cea204
--- /dev/null
+++ b/src/app/explorer/components/CIDInput.tsx
@@ -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 (
+
+
+
+
+
+
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"
+ />
+
+
+
+ Paste a CID to browse its contents
+
+
+ );
+}
diff --git a/src/app/explorer/components/DirectoryListing.tsx b/src/app/explorer/components/DirectoryListing.tsx
new file mode 100644
index 0000000..5e210f3
--- /dev/null
+++ b/src/app/explorer/components/DirectoryListing.tsx
@@ -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 (
+
+ );
+}
+
+export default function DirectoryListing({
+ entries,
+ loading,
+ onNavigate,
+ onPreview,
+ pins,
+}: DirectoryListingProps) {
+ if (loading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ if (entries.length === 0) {
+ return (
+
+
+
This directory is empty
+
+ );
+ }
+
+ const sorted = [...entries].sort((a, b) => {
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ return (
+
+ {/* Header row — hidden on smallest screens */}
+
+
+
Name
+
Type
+
Size
+
Hash
+
+
+
+
+ {sorted.map((entry) => {
+ const isPinned = pins.includes(entry.hash);
+ return (
+
+ entry.type === 'dir'
+ ? onNavigate(entry.hash, entry.name)
+ : onPreview(entry.hash)
+ }
+ >
+
+
+
+ {entry.name}
+
+
+
+ {entry.type}
+
+
+
+ {formatSize(entry.size)}
+
+
+
+ {truncateHash(entry.hash)}
+
+
+
+ {isPinned && (
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/explorer/components/FileIcon.tsx b/src/app/explorer/components/FileIcon.tsx
new file mode 100644
index 0000000..96df11f
--- /dev/null
+++ b/src/app/explorer/components/FileIcon.tsx
@@ -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 ;
+ }
+
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
+
+ switch (ext) {
+ case 'md':
+ return ;
+ case 'json':
+ return ;
+ case 'js':
+ case 'ts':
+ case 'py':
+ case 'css':
+ case 'html':
+ return ;
+ case 'jpg':
+ case 'jpeg':
+ case 'png':
+ case 'gif':
+ case 'webp':
+ case 'svg':
+ return ;
+ case 'pdf':
+ return ;
+ default:
+ return ;
+ }
+}
diff --git a/src/app/explorer/components/FilePreview.tsx b/src/app/explorer/components/FilePreview.tsx
new file mode 100644
index 0000000..6204ab7
--- /dev/null
+++ b/src/app/explorer/components/FilePreview.tsx
@@ -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, '$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/^# (.+)$/gm, '$1
')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/\*(.+?)\*/g, '$1')
+ .replace(/\[(.+?)\]\((.+?)\)/g, '$1')
+ .replace(/^- (.+)$/gm, '$1')
+ .replace(/`(.+?)`/g, '$1')
+ .replace(/\n\n/g, '
')
+ .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
{text};
+ }
+
+ const formatted = JSON.stringify(parsed, null, 2);
+
+ const colored = formatted.replace(
+ /("(?:\\.|[^"\\])*")\s*:/g,
+ '$1:',
+ ).replace(
+ /:\s*("(?:\\.|[^"\\])*")/g,
+ ': $1',
+ ).replace(
+ /:\s*(true|false)/g,
+ ': $1',
+ ).replace(
+ /:\s*(\d+\.?\d*)/g,
+ ': $1',
+ ).replace(
+ /:\s*(null)/g,
+ ': $1',
+ );
+
+ return (
+
+ );
+}
+
+export default function FilePreview({ cid, content, loading, onClose, filename }: FilePreviewProps) {
+ const fileType = detectFileType(filename);
+
+ return (
+
+ {/* Header */}
+
+
+
+ {filename || cid.slice(0, 16) + '…'}
+
+
+ {fileType}
+
+
+
+
+
+ {/* Content */}
+ {loading ? (
+
+ ) : fileType === 'image' ? (
+
+

+
+ ) : fileType === 'markdown' && content ? (
+
+ ) : fileType === 'json' && content ? (
+
+
+
+ ) : fileType === 'text' && content ? (
+
+ {content}
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/app/explorer/components/GatewayLink.tsx b/src/app/explorer/components/GatewayLink.tsx
new file mode 100644
index 0000000..752f781
--- /dev/null
+++ b/src/app/explorer/components/GatewayLink.tsx
@@ -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 (
+
+
+ {gatewayUrl}
+
+
+
+ );
+}
diff --git a/src/app/explorer/components/PinBadge.tsx b/src/app/explorer/components/PinBadge.tsx
new file mode 100644
index 0000000..09cce44
--- /dev/null
+++ b/src/app/explorer/components/PinBadge.tsx
@@ -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 (
+
+
+ Pinned
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/explorer/page.tsx b/src/app/explorer/page.tsx
new file mode 100644
index 0000000..e0a8da9
--- /dev/null
+++ b/src/app/explorer/page.tsx
@@ -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([]);
+ const [previewCid, setPreviewCid] = useState(null);
+ const [previewContent, setPreviewContent] = useState(null);
+ const [previewLoading, setPreviewLoading] = useState(false);
+ const [path, setPath] = useState([]);
+ const [state, setState] = useState('idle');
+ const [error, setError] = useState('');
+ const [pins, setPins] = useState([]);
+ const [recentCids] = useState(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 (
+
+ {/* Page header */}
+
+
IPFS Explorer
+
+ Browse IPFS content by CID — directories, files, and pin management
+
+
+
+ {/* CID input */}
+
+
+
+
+ {/* Recent CIDs suggestion */}
+ {state === 'idle' && recentCids.length > 0 && (
+
+
Recent CIDs
+
+ {recentCids.slice(0, 5).map((rc) => (
+
+ ))}
+
+
+ )}
+
+ {/* Idle state */}
+ {state === 'idle' && (
+
+
+
+
+
+ Enter a CID to browse IPFS content — directory listings, file previews, and pin management
+
+
+ )}
+
+ {/* Resolving IPNS */}
+ {state === 'resolving' && (
+
+
+
Resolving IPNS name...
+
+ )}
+
+ {/* Error state */}
+ {state === 'error' && (
+
+
+
+
+
+ Failed to browse content
+
+
{error}
+
+
+
+
+ )}
+
+ {/* Loaded state — breadcrumbs + directory listing */}
+ {(state === 'loaded' || state === 'loading') && (
+
+ {/* Breadcrumbs + Pin + Gateway row */}
+
+
+ {/* Directory listing */}
+
+
+ {/* File preview */}
+ {previewCid && (
+
+ )}
+
+ )}
+
+ );
+}
+
+function findFilename(entries: IPFSEntry[], hash: string): string | undefined {
+ return entries.find((e) => e.hash === hash)?.name;
+}
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..14b5556
--- /dev/null
+++ b/src/app/globals.css
@@ -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; }
diff --git a/src/app/history/page.tsx b/src/app/history/page.tsx
new file mode 100644
index 0000000..aa7f93a
--- /dev/null
+++ b/src/app/history/page.tsx
@@ -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([]);
+ const [search, setSearch] = useState('');
+ const [copied, setCopied] = useState(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 (
+
+
+ {/* ── Header ── */}
+
+
+
Upload History
+
+ {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'}
+
+
+ {history.length > 0 && (
+
+ )}
+
+
+ {/* ── Search ── */}
+ {history.length > 0 && (
+
+
+ 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 && (
+
+ )}
+
+ )}
+
+ {/* ── Empty state ── */}
+ {history.length === 0 && (
+
+
+
No upload history yet
+
+ Your uploaded files will appear here so you can easily copy CIDs, download files, or check upload details.
+
+
+
+ Upload your first file
+
+
+ )}
+
+ {/* ── Desktop table ── */}
+ {filtered.length > 0 && (
+ <>
+ {/* Desktop table — hidden on small screens */}
+
+
+
+
+ | File Name |
+ CID |
+ Size |
+ Date |
+ Method |
+ Actions |
+
+
+
+ {filtered.map((rec) => {
+ const badge = getMethodBadge(rec.method);
+ const gwLink = gatewayLink(rec.cid);
+ return (
+
+ {/* File name */}
+ |
+
+
+
+ {rec.name}
+
+
+ |
+
+ {/* CID */}
+
+
+ {truncateCid(rec.cid)}
+
+ |
+
+ {/* Size */}
+
+ {formatBytes(rec.size)}
+ |
+
+ {/* Date */}
+
+
+ {new Date(rec.date).toLocaleDateString()}
+ {new Date(rec.date).toLocaleTimeString()}
+
+ |
+
+ {/* Method badge */}
+
+
+ {badge.label}
+
+ |
+
+ {/* Actions */}
+
+
+ {/* Copy CID */}
+
+
+ {/* Copy gateway link */}
+
+
+ {/* Open in gateway */}
+
+
+
+
+ |
+
+ );
+ })}
+
+
+
+
+ {/* ── Mobile cards ── */}
+
+ {filtered.map((rec) => {
+ const badge = getMethodBadge(rec.method);
+ const gwLink = gatewayLink(rec.cid);
+ return (
+
+ {/* Name + method badge */}
+
+
+
+
+ {rec.name}
+
+
+
+ {badge.label}
+
+
+
+ {/* CID */}
+
+
+ {truncateCid(rec.cid)}
+
+
+
+
+ {/* Size + date */}
+
+ {formatBytes(rec.size)}
+
+
+ {new Date(rec.date).toLocaleDateString()}
+
+
+
+ {new Date(rec.date).toLocaleTimeString()}
+
+
+
+ {/* Actions row */}
+
+ {/* Copy CID */}
+
+
+ {/* Copy link */}
+
+
+ {/* Download / Open */}
+
+
+ Open
+
+
+
+ );
+ })}
+
+
+ {/* ── Results count ── */}
+ {search && filtered.length < history.length && (
+
+ Showing {filtered.length} of {history.length} uploads
+
+
+ )}
+ >
+ )}
+
+ {/* ── No search results ── */}
+ {history.length > 0 && filtered.length === 0 && (
+
+
+
+
+
No results found
+
+ No uploads match “{search}”
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/ipns/page.tsx b/src/app/ipns/page.tsx
new file mode 100644
index 0000000..fca72ff
--- /dev/null
+++ b/src/app/ipns/page.tsx
@@ -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([]);
+ const [status, setStatus] = useState('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 (
+
+ {/* Header */}
+
+
+
IPNS
+
+ InterPlanetary Name System — human-readable names for IPFS content
+
+
+
+
+
+ {/* Keys */}
+
+
+
+
+ Signing Keys
+
+ {keys.length} key{keys.length !== 1 ? 's' : ''}
+
+
+ {status === 'loading' && (
+
+
+ Loading keys...
+
+ )}
+
+ {status === 'error' && (
+
+ {error}
+
+ )}
+
+ {status === 'success' && keys.length === 0 && (
+ No IPNS keys found.
+ )}
+
+ {keys.length > 0 && (
+
+ {keys.map((key) => (
+
+
+
{key.name}
+
{key.id}
+
+
+
+ ))}
+
+ )}
+
+ {/* Generate key */}
+
+
+
+ {/* Two-column: Publish + Resolve */}
+
+ {/* Publish */}
+
+
+
+ Publish
+
+
+ Point an IPNS name to a CID. Publish is signed with the selected key.
+
+
+ {pubError && {pubError}
}
+ {pubResult && (
+
+
Name: {pubResult.name}
+
Value: {pubResult.value}
+
+ )}
+
+
+ {/* Resolve */}
+
+
+
+ Resolve
+
+
+ Resolve an IPNS name or peer ID to the current IPFS path.
+
+
+ {resolveError && {resolveError}
}
+ {resolveResult && (
+
+
Resolved to:
+
{resolveResult}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/layout-portal.tsx b/src/app/layout-portal.tsx
new file mode 100644
index 0000000..e5124f8
--- /dev/null
+++ b/src/app/layout-portal.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..90e1828
--- /dev/null
+++ b/src/app/layout.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 0000000..8a7fc37
--- /dev/null
+++ b/src/app/page.tsx
@@ -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(null);
+ const [error, setError] = useState(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 (
+
+ {/* Background glow */}
+
+
+
+ {/* Hero */}
+
+ {/* Badge */}
+
+
+ MAOSv6 · IPFS Gateway
+
+
+ {/* Title */}
+
+ IPFS{' '}
+
+ Portal
+
+
+
+ Decentralized storage management. Pin files, monitor peers, manage users.
+ All from your wallet.
+
+
+ {/* Feature pills */}
+
+ {[
+ { icon: HardDrive, label: 'Pin Manager' },
+ { icon: Globe, label: 'Peer Monitor' },
+ { icon: Zap, label: 'File Upload' },
+ { icon: Lock, label: 'Wallet Auth' },
+ ].map((f) => (
+
+
+ {f.label}
+
+ ))}
+
+
+ {/* Connect button */}
+
+
+ {/* Error */}
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {/* Footer */}
+
+ MAOSv6 · Chain 270
+
+
+ );
+}
diff --git a/src/app/peers/page.tsx b/src/app/peers/page.tsx
new file mode 100644
index 0000000..1bc509e
--- /dev/null
+++ b/src/app/peers/page.tsx
@@ -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 (
+
+
+
Peers
+
{peers.length} connected peers
+
+
+
+ {loading ? (
+
Loading…
+ ) : peers.length === 0 ? (
+
No peers connected
+ ) : (
+
+ {peers.map((p) => (
+
+
+
+
+
+
+
{p.id}
+
+ {p.addr}
+
+
+
+
+
+ {p.latency}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/pins/page.tsx b/src/app/pins/page.tsx
new file mode 100644
index 0000000..5e26aa7
--- /dev/null
+++ b/src/app/pins/page.tsx
@@ -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(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 (
+
+
+
+
Pins
+
{pins.length} pinned CIDs
+
+
+
+
+ {/* Add pin form */}
+ {showAdd && (
+
+ )}
+
+ {/* Search */}
+
+
+ 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"
+ />
+
+
+ {/* Pin list */}
+
+ {loading ? (
+
Loading…
+ ) : filtered.length === 0 ? (
+
+ {search ? 'No pins match your search' : 'No pins yet. Add one above.'}
+
+ ) : (
+
+ {filtered.map((pin) => (
+
+
+
+
+
+ {pin.name || pin.cid}
+
+
+
+ {(pin.size / 1024).toFixed(1)} KB
+ {pin.created && {pin.created}}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
new file mode 100644
index 0000000..8af6ead
--- /dev/null
+++ b/src/app/settings/page.tsx
@@ -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(() => getSettings());
+ const [original, setOriginal] = useState(() => getSettings());
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState(null);
+ const [validation, setValidation] = useState>({});
+
+ const dirty = JSON.stringify(form) !== JSON.stringify(original);
+
+ /* ── Field update ── */
+ function update(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 = {};
+ 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] ? (
+ {validation[field]}
+ ) : null;
+ }
+
+ /* ════════════ Sections ════════════ */
+
+ const sections = [
+ {
+ title: 'IPFS Gateway',
+ icon: Globe,
+ iconColor: 'text-accent-cyan',
+ fields: (
+
+
+
update('gatewayUrl', e.target.value)}
+ placeholder="https://ipfs.io/ipfs"
+ className={inputCls('gatewayUrl')}
+ />
+ {errorMsg('gatewayUrl')}
+
+ Base URL for IPFS gateway links. Used in dashboard, history, and share links.
+
+
+ ),
+ },
+ {
+ title: 'API Endpoint',
+ icon: Server,
+ iconColor: 'text-accent-purple',
+ fields: (
+
+
+
update('apiEndpoint', e.target.value)}
+ placeholder="(same-origin)"
+ className={inputCls('apiEndpoint')}
+ />
+ {errorMsg('apiEndpoint')}
+
+ Leave empty to use the same origin. Set to a custom URL to proxy through another server.
+
+
+ ),
+ },
+ {
+ title: 'Storage',
+ icon: HardDrive,
+ iconColor: 'text-accent-amber',
+ fields: (
+
+
+
+ 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' }}
+ />
+ 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"
+ />
+ MB
+
+ {errorMsg('storageMax')}
+
+ Maximum storage the IPFS node should use. Applied server-side.
+
+
+ ),
+ },
+ {
+ title: 'Auto-Refresh',
+ icon: RefreshCw,
+ iconColor: 'text-accent-green',
+ fields: (
+
+
+
+ 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' }}
+ />
+ 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"
+ />
+ sec
+
+ {errorMsg('refreshInterval')}
+
+ How often the dashboard auto-refreshes peer/bandwidth data.
+
+
+ ),
+ },
+ {
+ title: 'Theme',
+ icon: form.theme === 'dark' ? Moon : Sun,
+ iconColor: 'text-accent-amber',
+ fields: (
+
+
+
+ {(['dark', 'light'] as const).map(theme => (
+
+ ))}
+
+
+ Theme preference is saved but only affects new sessions. Full theme switching coming soon.
+
+
+ ),
+ },
+ ];
+
+ return (
+
+ {/* ── Header ── */}
+
+
Settings
+
IPFS Portal configuration
+
+
+ {/* ── Settings grid ── */}
+
+ {sections.map((s) => (
+
+
+
+
{s.title}
+
+ {s.fields}
+
+ ))}
+
+
+ {/* ── Actions + status ── */}
+
+
+ {/* Save */}
+
+
+ {/* Reset */}
+
+
+ {/* Dirty indicator */}
+ {dirty && !saved && (
+
+
+ Unsaved changes
+
+ )}
+
+ {/* Error */}
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx
new file mode 100644
index 0000000..32164fb
--- /dev/null
+++ b/src/app/upload/page.tsx
@@ -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(null);
+ const cryptoInputRef = useRef(null);
+
+ /* ── Tab state ── */
+ const [tab, setTab] = useState('quick');
+
+ /* ── Quick Upload state ── */
+ const [files, setFiles] = useState([]);
+ const [dragOver, setDragOver] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [uploadIndex, setUploadIndex] = useState(0);
+ const [uploadDone, setUploadDone] = useState(false);
+ const [copied, setCopied] = useState(null);
+
+ /* ── Crypto Upload state ── */
+ const [cryptoFile, setCryptoFile] = useState(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) {
+ 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) {
+ 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 (
+
+
+
Upload
+
Upload files to IPFS — quick or with crypto payment
+
+
+ {/* ── Tab switcher ── */}
+
+
+
+
+
+ {/* ════════════ TAB: Quick Upload ════════════ */}
+ {tab === 'quick' && (
+
+ {/* ── Drop zone ── */}
+ {!uploading && !uploadDone && (
+
+
= MAX_FILES}
+ />
+
+
+
+
+ Click to browse or drag & drop files
+
+
+ Up to {MAX_FILES} files, max {formatBytes(MAX_FILE_SIZE)} each
+
+
+
+
+ )}
+
+ {/* ── File list ── */}
+ {files.length > 0 && (
+
+
+
+ {totalCount} file{totalCount !== 1 ? 's' : ''} selected
+ {uploading && <> · Uploading {uploadIndex}/{totalCount}>}
+
+ {!uploading && !allDone && (
+
+ )}
+
+
+ {/* Progress bar */}
+ {uploading && (
+
+ )}
+
+ {/* Files */}
+
+ {files.map((entry, idx) => (
+
+ {/* Preview / icon */}
+
+ {entry.preview ? (
+

+ ) : (
+
+ )}
+
+
+ {/* Info */}
+
+
+ {entry.file.name}
+ {entry.status === 'done' && entry.result && (
+
+ )}
+ {entry.status === 'error' && (
+
+ )}
+
+
+ {formatBytes(entry.file.size)}
+ {entry.status === 'uploading' && (
+
+
+ Uploading…
+
+ )}
+ {entry.status === 'done' && entry.result && (
+
+ {entry.result.cid.slice(0, 16)}…
+
+ )}
+ {entry.status === 'error' && (
+
+ {entry.errorMsg}
+
+ )}
+
+
+
+ {/* Actions */}
+
+ {entry.status === 'done' && entry.result && (
+ <>
+
+
+
+
+ >
+ )}
+ {entry.status === 'pending' && !uploading && (
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {/* ── Action buttons ── */}
+ {files.length > 0 && !allDone && (
+
+
+
+ )}
+
+ {/* ── Results summary ── */}
+ {uploadDone && (
+
+
+
+ {errorCount === 0 ? (
+
+ ) : (
+
+ )}
+
+ {doneCount}/{totalCount} files uploaded
+
+
+
+
+
+ {/* Per-file result details */}
+ {files.filter(f => f.status === 'done').map(entry => entry.result && (
+
+
+ {entry.file.name}
+ {entry.result.cid.slice(0, 16)}…
+ {formatBytes(entry.result.size)}
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* ── Empty state ── */}
+ {files.length === 0 && !uploading && (
+
+
+
+ Drag files above or click to browse. No crypto payment needed for quick uploads.
+
+
+ )}
+
+ )}
+
+ {/* ════════════ TAB: Crypto Payment ════════════ */}
+ {tab === 'crypto' && (
+
+ {/* Left: file selection */}
+
+ {!cryptoFile ? (
+
+
+
+
+
+
+ Click to select a file
+
+
+ Single file only. Pay with ETH, USDC, or MAOS.
+
+
+
+
+ ) : (
+
+
+
+
+
{cryptoFile.name}
+
{formatBytes(cryptoFile.size)}
+
+
+
+
+ {/* Crypto result */}
+ {cryptoResult && (
+
+
+
+ Upload complete
+
+
+
CID
+
+ {cryptoResult.cid}
+
+
+
+
+
+ )}
+
+ )}
+
+
+ {/* Right: PaymentPanel */}
+
+ {cryptoFile ? (
+
+ ) : (
+
+
+
+ Select a file to see pricing
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/users/page.tsx b/src/app/users/page.tsx
new file mode 100644
index 0000000..ddec047
--- /dev/null
+++ b/src/app/users/page.tsx
@@ -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(null);
+ const [walletLoading, setWalletLoading] = useState(false);
+ const [walletError, setWalletError] = useState(null);
+ const [copiedCid, setCopiedCid] = useState(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 (
+
+ {/* ── Header with count badge ── */}
+
+
+
Users
+
{users.length} IPFS users
+
+
+
+
+ {/* ── Add user form ── */}
+ {showAdd && (
+
+ )}
+
+ {/* ── Two-column layout ── */}
+
+ {/* Left: htpasswd user list */}
+
+
+ {loading ? (
+
Loading…
+ ) : users.length === 0 ? (
+
No users configured
+ ) : (
+
+ {users.map((u) => (
+
+
+
+
+
+
+
{u.username}
+
Created {u.created}
+
+
+
+ {u.active && }
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Right: Wallet lookup */}
+
+
+
+ Wallet Lookup
+
+
Enter a wallet address to see on-chain upload stats
+
+ 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"
+ />
+
+
+ {walletError && (
+
+ {walletError}
+
+ )}
+
+
+ {/* ── Wallet stats ── */}
+ {walletStats && (
+ <>
+ {/* Stats cards */}
+
+
+
+ Total Bytes
+
+
{formatBytes(walletStats.totalBytes)}
+
+
+
+ Uploads
+
+
{walletStats.uploadCount}
+
+
+
+ Free Remaining
+
+
{formatBytes(walletStats.remainingFree)}
+
+
+
+ Address
+
+
+ {walletAddr.slice(0, 6)}...{walletAddr.slice(-4)}
+
+
+
+
+ {/* ── Upload history ── */}
+ {walletStats.uploads.length > 0 && (
+
+
+
Upload History ({walletStats.uploads.length})
+
+
+ {[...walletStats.uploads].reverse().map((rec, i) => (
+
+
+
+ {rec.tokenSymbol}
+ {formatBytes(rec.bytesSize)}
+
+
{formatTimestamp(rec.timestamp)}
+
+
+
+ {rec.cid}
+
+
+ {rec.amountPaid > BigInt(0) && (
+
+ {rec.tokenSymbol === 'ETH' ? formatWeiToETH(rec.amountPaid, 6) : rec.amountPaid.toString()} {rec.tokenSymbol}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {walletStats.uploads.length === 0 && (
+
+
No uploads found for this address
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/components/PaymentPanel.tsx b/src/components/PaymentPanel.tsx
new file mode 100644
index 0000000..fa24339
--- /dev/null
+++ b/src/components/PaymentPanel.tsx
@@ -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 = {
+ 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(null);
+ const [provider, setProvider] = useState(null);
+ const [connecting, setConnecting] = useState(false);
+ const [walletType, setWalletType] = useState(null);
+ const [wrongChain, setWrongChain] = useState(false);
+
+ // Contract state
+ const [deployed, setDeployed] = useState(false);
+ const [price, setPrice] = useState(null);
+ const [userStats, setUserStats] = useState(null);
+ const [tokens, setTokens] = useState([]);
+ const [discountTiers, setDiscountTiers] = useState([]);
+ const [selectedToken, setSelectedToken] = useState('ETH');
+ const [freeTierBytes, setFreeTierBytes] = useState(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(null);
+ const [txHash, setTxHash] = useState(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 (
+
+ {/* ── Header ── */}
+
+
+
+
Crypto Payment
+
+ {address && walletType && (
+
+ {walletType}
+
+ )}
+
+
+ {!deployed ? (
+ /* ── Not deployed ── */
+
+
+ Payment contract not deployed — uploads are free
+
+ ) : !address ? (
+ /* ── Connect wallet ── */
+
+ ) : wrongChain ? (
+ /* ── Wrong chain ── */
+
+
+ Please switch to zkSync Local (chain 270)
+
+ ) : (
+ /* ── Connected — show pricing ── */
+
+ {/* Wallet address */}
+
+ Connected
+
+ {address.slice(0, 6)}...{address.slice(-4)}
+
+
+
+ {/* Free tier info */}
+ {userStats && (
+
+
+
+ Free tier
+
+
+ {formatBytes(userStats.remainingFree)} / {formatBytes(freeTierBytes)} remaining
+
+
+ )}
+
+ {/* Price info */}
+ {price && (
+
+
+ File size
+ {formatBytes(fileSize)}
+
+
+ {price.isFree ? (
+
+
+ Free upload (within free tier)
+
+ ) : (
+ <>
+
+ Price
+ {formatWeiToETH(price.totalWei, 6)} ETH
+
+
+ {price.discountBps > 0 && (
+
+
+
+ MAOS discount
+
+
-{price.discountBps / 100}%
+
+ )}
+
+
+ Final
+
+ {formatWeiToETH(price.finalWei, 6)} ETH
+
+
+
+ {/* Token selector */}
+ {tokens.length > 1 && (
+
+
+
+ {tokens.filter(t => t.enabled || t.symbol === 'ETH').map(token => (
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ )}
+
+ {/* Action button */}
+
+
+ )}
+
+ {/* ── Result ── */}
+ {uploadResult && status === 'complete' && (
+
+
+
+ Upload + Payment successful
+
+
+ CID
+
+
+ {txHash && (
+
+ )}
+
+ )}
+
+ {/* ── Error ── */}
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {/* ── MAOS Discount tiers ── */}
+ {address && discountTiers.length > 0 && (
+
+
+ MAOS discount tiers
+
+
+ {discountTiers.map((tier, i) => (
+
+ {formatWeiToETH(tier.minBalance, 0)} MAOS
+ {tier.discountBps / 100}% off
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/PortalHeader.tsx b/src/components/PortalHeader.tsx
new file mode 100644
index 0000000..b658c1f
--- /dev/null
+++ b/src/components/PortalHeader.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/PortalSidebar.tsx b/src/components/PortalSidebar.tsx
new file mode 100644
index 0000000..a69bf5b
--- /dev/null
+++ b/src/components/PortalSidebar.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
new file mode 100644
index 0000000..6d55d68
--- /dev/null
+++ b/src/lib/api.ts
@@ -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(path: string, options?: RequestInit): Promise {
+ 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 {
+ 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 {
+ const raw = await apiFetch<{ Keys?: Record }>('/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 {
+ 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 {
+ const raw = await apiFetch('/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 {
+ 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 {
+ return apiFetch(`/explorer/ls/${encodeURIComponent(cid)}`, { method: 'POST' });
+}
+
+export async function explorerCat(cid: string): Promise {
+ 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> {
+ return apiFetch(`/explorer/stat/${encodeURIComponent(cid)}`, { method: 'POST' });
+}
+
+/* ── IPNS ── */
+
+export interface IPNSKey {
+ name: string;
+ id: string;
+}
+
+export async function listIPNSKeys(): Promise {
+ 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 {
+ const res = await apiFetch<{ Path: string }>(`/ipns/resolve/${encodeURIComponent(name)}`);
+ return res.Path || '';
+}
+
+export async function ipnsGenKey(name: string): Promise {
+ 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 {
+ 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 {
+ 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');
+}
diff --git a/src/lib/payment.ts b/src/lib/payment.ts
new file mode 100644
index 0000000..f784a52
--- /dev/null
+++ b/src/lib/payment.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const client = this.getPublicClient();
+ return client.readContract({
+ address: this.contractAddress,
+ abi: IPFS_PORTAL_PAYMENT_ABI,
+ functionName: 'freeTierBytes',
+ args: [],
+ }) as Promise;
+ }
+
+ /* ── READ: Get base price per MB ── */
+ async getBasePricePerMB(): Promise {
+ const client = this.getPublicClient();
+ return client.readContract({
+ address: this.contractAddress,
+ abi: IPFS_PORTAL_PAYMENT_ABI,
+ functionName: 'basePricePerMB',
+ args: [],
+ }) as Promise;
+ }
+
+ /* ── WRITE: Pay with ETH ── */
+ async payWithETH(
+ provider: WalletProvider,
+ cid: string,
+ bytesSize: number,
+ priceWei: bigint,
+ ): Promise {
+ 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 {
+ const client = this.getPublicClient();
+ return client.readContract({
+ address: this.contractAddress,
+ abi: IPFS_PORTAL_PAYMENT_ABI,
+ functionName: 'owner',
+ args: [],
+ }) as Promise;
+ }
+
+ /* ── READ: Get total revenue for a token ── */
+ async getTotalRevenue(tokenSymbol: string): Promise {
+ const client = this.getPublicClient();
+ return client.readContract({
+ address: this.contractAddress,
+ abi: IPFS_PORTAL_PAYMENT_ABI,
+ functionName: 'totalRevenue',
+ args: [tokenSymbol],
+ }) as Promise;
+ }
+
+ /* ── READ: Get total upload count ── */
+ async getTotalUploads(): Promise {
+ const client = this.getPublicClient();
+ return client.readContract({
+ address: this.contractAddress,
+ abi: IPFS_PORTAL_PAYMENT_ABI,
+ functionName: 'totalUploads',
+ args: [],
+ }) as Promise;
+ }
+
+ /* ── READ: Get supported token symbols (string[]) ── */
+ async getTokenSymbols(): Promise {
+ const client = this.getPublicClient();
+ return client.readContract({
+ address: this.contractAddress,
+ abi: IPFS_PORTAL_PAYMENT_ABI,
+ functionName: 'getSupportedTokens',
+ args: [],
+ }) as Promise;
+ }
+
+ /* ── WRITE: Admin — set price per MB (onlyOwner) ── */
+ async adminSetPricePerMB(provider: WalletProvider, priceWei: bigint): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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();
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
new file mode 100644
index 0000000..d199194
--- /dev/null
+++ b/src/lib/storage.ts
@@ -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 {
+ 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 */ }
+}
diff --git a/src/lib/wallet.ts b/src/lib/wallet.ts
new file mode 100644
index 0000000..0706684
--- /dev/null
+++ b/src/lib/wallet.ts
@@ -0,0 +1,196 @@
+/* ── Wallet connection (EIP-6963 + MAOS extension) ── */
+
+/* ── Types ── */
+export interface WalletProvider {
+ request: (args: { method: string; params?: unknown[] }) => Promise;
+ 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 {
+ 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 {
+ 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 {
+ return provider.request({
+ method: 'personal_sign',
+ params: [message, address],
+ });
+}
+
+/* ── Get ETH balance ── */
+export async function getBalance(address: string): Promise {
+ 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`;
+}
diff --git a/tests/portal.spec.ts b/tests/portal.spec.ts
new file mode 100644
index 0000000..f9283ba
--- /dev/null
+++ b/tests/portal.spec.ts
@@ -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');
+ }
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..a157def
--- /dev/null
+++ b/tsconfig.json
@@ -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"
+ ]
+}