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 && ( +
+

Configure Token

+
+ setTokenSymbol(e.target.value.toUpperCase())} + placeholder="Symbol (e.g. USDC)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500" + /> + setTokenAddr(e.target.value)} + placeholder="Token address (0x...)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500" + /> + setTokenDec(e.target.value)} + placeholder="Decimals (e.g. 6)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500" + /> + setTokenWei(e.target.value)} + placeholder="weiPerToken (e.g. 500000000000000)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500" + /> + + +
+
+ )} + + {/* 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 && ( +
+
+ refreshing… +
+ )} +
+ + {/* 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 +

+
+
+
+ + In +
+ {bwIn} MB +
+
+
+ + Out +
+ {bwOut} MB +
+
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' ? ( +
    + {filename +
    + ) : fileType === 'markdown' && content ? ( +
    + ) : fileType === 'json' && content ? ( +
    + +
    + ) : fileType === 'text' && content ? ( +
    +          {content}
    +        
    + ) : ( +
    +

    + Preview not available for this file type +

    + + + Download file + +
    + )} +
    + ); +} 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 */} +
    + + + + + + + + + + + + + {filtered.map((rec) => { + const badge = getMethodBadge(rec.method); + const gwLink = gatewayLink(rec.cid); + return ( + + {/* File name */} + + + {/* CID */} + + + {/* Size */} + + + {/* Date */} + + + {/* Method badge */} + + + {/* Actions */} + + + ); + })} + +
    File NameCIDSizeDateMethodActions
    +
    + + + {rec.name} + +
    +
    + + {truncateCid(rec.cid)} + + + {formatBytes(rec.size)} + +
    +
    {new Date(rec.date).toLocaleDateString()}
    +
    {new Date(rec.date).toLocaleTimeString()}
    +
    +
    + + {badge.label} + + +
    + {/* 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 */} +
    + +
    + setGenName(e.target.value)} + placeholder="key-name" + className="flex-1 px-3 py-1.5 text-sm bg-surface-900 border border-surface-700 rounded-lg text-surface-200 placeholder-surface-600 focus:outline-none focus:border-brand-500/50" + required + /> + +
    + {genError &&

    {genError}

    } +
    +
    + + {/* Two-column: Publish + Resolve */} +
    + {/* Publish */} +
    +

    + + Publish +

    +

    + Point an IPNS name to a CID. Publish is signed with the selected key. +

    +
    +
    + + setPubCid(e.target.value)} + placeholder="Qm... or bafy..." + className="w-full px-3 py-1.5 text-sm bg-surface-900 border border-surface-700 rounded-lg text-surface-200 placeholder-surface-600 focus:outline-none focus:border-brand-500/50 font-mono" + required + /> +
    +
    + + +
    + +
    + {pubError &&

    {pubError}

    } + {pubResult && ( +
    +

    Name: {pubResult.name}

    +

    Value: {pubResult.value}

    +
    + )} +
    + + {/* Resolve */} +
    +

    + + Resolve +

    +

    + Resolve an IPNS name or peer ID to the current IPFS path. +

    +
    +
    + + setResolveName(e.target.value)} + placeholder="k51... or /ipns/example.com" + className="w-full px-3 py-1.5 text-sm bg-surface-900 border border-surface-700 rounded-lg text-surface-200 placeholder-surface-600 focus:outline-none focus:border-brand-500/50 font-mono" + required + /> +
    + +
    + {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 ( +
    + +
    + +
    {children}
    +
    +
    + ); +} 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 && ( +
    +
    + setNewCid(e.target.value)} + placeholder="CID to pin…" + className="flex-1 px-3 py-2 rounded-lg bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500" + /> + setNewName(e.target.value)} + placeholder="Name (optional)" + className="w-40 px-3 py-2 rounded-lg bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500" + /> + +
    +
    + )} + + {/* 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} + +
    +
    +
    + Gateway + + View + +
    +
    + )} +
    + )} +
    + + {/* 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 && ( +
    +
    + setNewUser(e.target.value)} + placeholder="Username" + className="flex-1 px-3 py-2 rounded-lg bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500" + /> + setNewPass(e.target.value)} + type="password" placeholder="Password" + className="flex-1 px-3 py-2 rounded-lg bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500" + /> + +
    +
    + )} + + {/* ── 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 ( +
    + {/* Page title slot — parent fills with children */} +
    + + {/* Right side */} +
    + {/* Node status */} +
    + + + {nodeStatus === 'online' ? `Node ${nodeVersion}` : 'Offline'} + +
    + + {/* Wallet badge placeholder */} +
    + + Wallet Connected +
    +
    +
    + ); +} 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" + ] +}