1
0
forked from maik/IPFS-portal

refactor: codebase opschoning — type safety, error handling, central config, page splits

- categorie A: alle catch(e: any) → catch(e: unknown) met instanceof check
- categorie B: stille .catch() logging toegevoegd (SWRegister, admin, upload, auth)
- categorie C: hardcoded 192.168.1.176 IPs vervangen door env var defaults
- categorie D: page files >250L gesplitst (history, settings, explorer, admin/payment,
  ipns, users, upload, dashboard) in helpers/components
- categorie E: eslint-disable vervangen in SearchInput.tsx
- src/lib/config.ts centrale config module (localhost defaults)
- CSP in next.config.ts dynamisch via env var
- deploy.sh: geen hardcoded IP meer (REQUIRED arg)
- lib/api/ lib/auth/ lib/wallet/ lib/search-index/ lib/storage/ gesplitst in modules
- ongebruikte bestanden verwijderd: api.ts, auth.tsx, wallet.ts, search-index.ts,
  PaymentPanel.tsx, SearchBar.tsx, proxy.ts, serve-static.js
This commit is contained in:
maikrolf
2026-07-19 15:35:18 +02:00
parent 88ce38a49e
commit f72b775379
144 changed files with 4644 additions and 10230 deletions
-1
View File
@@ -3,6 +3,5 @@ node_modules
.gitignore .gitignore
*.md *.md
.env .env
.env.local
.next .next
out out
+24
View File
@@ -0,0 +1,24 @@
# ── IPFS Portal Environment Variables ──
# Copy to .env.local (dev) or .env.production (prod)
# JWT secret for session cookies (REQUIRED in production — generate with: openssl rand -hex 32)
# JWT_SECRET=
# Python User Management API
# USER_API_BASE=http://localhost:8444
# USER_API_ADMIN_KEY=
# Kubo RPC API (optional — if behind proxy)
# KUBO_API_URL=http://localhost:5001
# KUBO_BASIC_AUTH=user:password
# zkSync Local RPC + Explorer (smart contract chain 270)
# ZKSYNC_RPC_URL=http://localhost:3050
# NEXT_PUBLIC_ZKSYNC_RPC_URL=http://localhost:3050
# NEXT_PUBLIC_ZKSYNC_EXPLORER_URL=http://localhost:3050
# Contract address (default deployed on chain 270)
# PAYMENT_CONTRACT_ADDRESS=0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe
# IPFS Gateway (for share links)
# NEXT_PUBLIC_GATEWAY_URL=https://ipfs.maos.dedyn.io
+4
View File
@@ -1,3 +1,7 @@
# Tool artifacts
.omo/
graphify-out/
# Dependencies # Dependencies
node_modules/ node_modules/
+9 -7
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# ── IPFS Portal Deploy Script ── # ── IPFS Portal Deploy Script ──
# Usage: ./deploy.sh [remote_host] [remote_port] # Usage: ./deploy.sh [remote_host] [remote_port]
# remote_host: SSH target (default: maik@192.168.1.176) # remote_host: SSH target (required — e.g. maik@your-server)
# remote_port: SSH port (default: 22) # remote_port: SSH port (default: 22)
# #
# Builds Docker image locally, pushes to Docker Hub or saves as tar, # Builds Docker image locally, pushes to Docker Hub or saves as tar,
@@ -19,7 +19,7 @@
set -euo pipefail set -euo pipefail
REMOTE_HOST="${1:-maik@192.168.1.176}" REMOTE_HOST="${1:?Usage: $0 <user@host> [port]}"
REMOTE_PORT="${2:-22}" REMOTE_PORT="${2:-22}"
IMAGE_NAME="maos/ipfs-portal" IMAGE_NAME="maos/ipfs-portal"
TAG="$(date +%Y%m%d-%H%M%S)" TAG="$(date +%Y%m%d-%H%M%S)"
@@ -81,10 +81,12 @@ services:
- NODE_ENV=production - NODE_ENV=production
- PORT=3445 - PORT=3445
- NEXT_TELEMETRY_DISABLED=1 - NEXT_TELEMETRY_DISABLED=1
- USER_API_BASE=http://192.168.1.176:8444 # ── Set these via .env.production or environment ──
- USER_API_ADMIN_KEY=maos-admin-2024 - USER_API_BASE=${USER_API_BASE:-http://localhost:8444}
- KUBO_API_URL=http://192.168.1.176:8443 # - USER_API_ADMIN_KEY= # ← REQUIRED: set this!
- KUBO_BASIC_AUTH=portal:ipfs-portal-2024 # - KUBO_API_URL= # ← Kubo RPC if behind proxy
# - KUBO_BASIC_AUTH= # ← Kubo basic auth credentials
# - JWT_SECRET= # ← REQUIRED: generate a random string
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3445/api/health"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3445/api/health"]
interval: 30s interval: 30s
@@ -111,4 +113,4 @@ REMOTESHELL
echo "" echo ""
echo "═══ Deploy complete ═══" echo "═══ Deploy complete ═══"
echo " URL: https://maos.dedyn.io (via nginx) or http://192.168.1.176:3445" echo " URL: https://maos.dedyn.io (via nginx)"
-1
View File
@@ -1 +0,0 @@
{"0": "Portal Layout & Navigation", "1": "Landing & Payment UI", "2": "Payment Smart Contract", "3": "IPFS Gateway & History", "4": "Explorer Components", "5": "NPM Dependencies", "6": "TypeScript Config", "7": "API Routes & Proxy", "8": "File Preview", "9": "Static Server", "10": "Root Layout", "11": "Sidebar Navigation", "12": "Deploy Script", "13": "Next.js Config", "14": "Playwright Config", "15": "PostCSS Config", "16": "E2E Tests"}
-1
View File
@@ -1 +0,0 @@
C:\Users\maik\AppData\Local\Microsoft\WindowsApps\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\python.exe
-1
View File
@@ -1 +0,0 @@
H:\IPFS-portal
-111
View File
@@ -1,111 +0,0 @@
# Graph Report - . (2026-06-27)
## Corpus Check
- Corpus is ~21,600 words - fits in a single context window. You may not need a graph.
## Summary
- 251 nodes · 401 edges · 17 communities (12 shown, 5 thin omitted)
- Extraction: 100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS · INFERRED: 2 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
- [[_COMMUNITY_Portal Layout & Navigation|Portal Layout & Navigation]]
- [[_COMMUNITY_Landing & Payment UI|Landing & Payment UI]]
- [[_COMMUNITY_Payment Smart Contract|Payment Smart Contract]]
- [[_COMMUNITY_IPFS Gateway & History|IPFS Gateway & History]]
- [[_COMMUNITY_Explorer Components|Explorer Components]]
- [[_COMMUNITY_NPM Dependencies|NPM Dependencies]]
- [[_COMMUNITY_TypeScript Config|TypeScript Config]]
- [[_COMMUNITY_API Routes & Proxy|API Routes & Proxy]]
- [[_COMMUNITY_File Preview|File Preview]]
- [[_COMMUNITY_Static Server|Static Server]]
- [[_COMMUNITY_Root Layout|Root Layout]]
- [[_COMMUNITY_Sidebar Navigation|Sidebar Navigation]]
- [[_COMMUNITY_Deploy Script|Deploy Script]]
- [[_COMMUNITY_Next.js Config|Next.js Config]]
## God Nodes (most connected - your core abstractions)
1. `PaymentService` - 31 edges
2. `compilerOptions` - 16 edges
3. `WalletProvider` - 15 edges
4. `apiFetch()` - 12 edges
5. `getInjectedProvider()` - 7 edges
6. `GET()` - 6 edges
7. `formatWeiToETH()` - 6 edges
8. `proxyIPFS()` - 5 edges
9. `POST()` - 5 edges
10. `DELETE()` - 5 edges
## Surprising Connections (you probably didn't know these)
- `AdminPaymentPage()` --calls--> `formatWeiToETH()` [EXTRACTED]
src/app/admin/payment/page.tsx → src/lib/wallet.ts
- `UploadPage()` --calls--> `GatewayLink()` [INFERRED]
src/app/upload/page.tsx → src/app/explorer/components/GatewayLink.tsx
- `PaymentPanel()` --calls--> `formatWeiToETH()` [EXTRACTED]
src/components/PaymentPanel.tsx → src/lib/wallet.ts
- `DirectoryListingProps` --references--> `IPFSEntry` [EXTRACTED]
src/app/explorer/components/DirectoryListing.tsx → src/lib/api.ts
## Import Cycles
- None detected.
## Communities (17 total, 5 thin omitted)
### Community 0 - "Portal Layout & Navigation"
Cohesion: 0.08
Nodes (28): PinBadgeProps, PeerData, PinData, Status, addPin(), apiFetch(), BWStats, checkHealth() (+20 more)
### Community 1 - "Landing & Payment UI"
Cohesion: 0.08
Nodes (30): PaymentPanel(), PaymentPanelProps, TOKEN_ICONS, listUsers(), ERC20_ABI, IPFS_PORTAL_PAYMENT_ABI, MAOSDiscountTier, PriceInfo (+22 more)
### Community 3 - "IPFS Gateway & History"
Cohesion: 0.11
Nodes (21): GatewayLink(), GatewayLinkProps, formatBytes(), HistoryPage(), uploadFile(), addToHistory(), clearHistory(), DEFAULT_SETTINGS (+13 more)
### Community 4 - "Explorer Components"
Cohesion: 0.09
Nodes (13): BreadcrumbSegment, BreadcrumbsProps, CIDInputProps, DirectoryListingProps, FileIconProps, BreadcrumbSegment, ExplorerPage(), ExplorerState (+5 more)
### Community 5 - "NPM Dependencies"
Cohesion: 0.08
Nodes (24): dependencies, clsx, lucide-react, next, postcss, react, react-dom, @tailwindcss/postcss (+16 more)
### Community 6 - "TypeScript Config"
Cohesion: 0.10
Nodes (19): compilerOptions, allowJs, esModuleInterop, incremental, isolatedModules, jsx, lib, module (+11 more)
### Community 7 - "API Routes & Proxy"
Cohesion: 0.44
Nodes (10): DELETE(), GET(), handleHealth(), mapToKuboAPI(), matchRoute(), POST(), proxyIPFS(), proxyResult() (+2 more)
### Community 8 - "File Preview"
Cohesion: 0.38
Nodes (4): detectFileType(), FilePreview(), FilePreviewProps, renderMarkdown()
### Community 9 - "Static Server"
Cohesion: 0.33
Nodes (5): fs, http, MIME, path, ROOT
## Knowledge Gaps
- **79 isolated node(s):** `deploy.sh script`, `nextConfig`, `name`, `version`, `private` (+74 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **5 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `PaymentService` connect `Payment Smart Contract` to `Landing & Payment UI`?**
_High betweenness centrality (0.153) - this node is a cross-community bridge._
- **Why does `http` connect `Static Server` to `Payment Smart Contract`?**
_High betweenness centrality (0.029) - this node is a cross-community bridge._
- **Why does `WalletProvider` connect `Payment Smart Contract` to `Landing & Payment UI`?**
_High betweenness centrality (0.017) - this node is a cross-community bridge._
- **What connects `deploy.sh script`, `nextConfig`, `name` to the rest of the system?**
_79 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Portal Layout & Navigation` be split into smaller, more focused modules?**
_Cohesion score 0.08194905869324474 - nodes in this community are weakly interconnected._
- **Should `Landing & Payment UI` be split into smaller, more focused modules?**
_Cohesion score 0.08305647840531562 - nodes in this community are weakly interconnected._
- **Should `IPFS Gateway & History` be split into smaller, more focused modules?**
_Cohesion score 0.1111111111111111 - nodes in this community are weakly interconnected._
-20
View File
@@ -1,20 +0,0 @@
import json
from pathlib import Path
g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8'))
nodes = g.get('nodes', [])
edges = g.get('edges', [])
print(f'Nodes: {len(nodes)}, Edges: {len(edges)}')
communities = g.get('communities', g.get('clusters', {}))
if communities:
print(f'Communities: {len(communities)}')
# Show node labels and types
for n in nodes[:40]:
nid = n.get('id', '?')
label = n.get('label', n.get('name', '?'))
ntype = n.get('type', n.get('node_type', '?'))
print(f' {nid}: {label} [{ntype}]')
if len(nodes) > 40:
print(f' ... and {len(nodes)-40} more')
-17
View File
@@ -1,17 +0,0 @@
import json
from pathlib as Path
from collections import Counter
g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8'))
nodes = g.get('nodes', [])
# Show all unique node labels to understand project structure
types = Counter()
labels = []
for n in nodes:
label = n.get('label', n.get('name', '?'))
labels.append(label)
# Show all labels
for lbl in sorted(labels):
print(lbl)
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
{"nodes": [{"id": "h_ipfs_portal_deploy_sh", "label": "deploy.sh", "file_type": "code", "source_file": "deploy.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "file"}}, {"id": "h_ipfs_portal_deploy_sh__entry", "label": "deploy.sh script", "file_type": "code", "source_file": "deploy.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "bash_entrypoint"}}], "edges": [{"source": "h_ipfs_portal_deploy_sh", "target": "h_ipfs_portal_deploy_sh__entry", "relation": "contains", "confidence": "EXTRACTED", "source_file": "deploy.sh", "source_location": "L1", "weight": 1.0}]}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"H:\\IPFS-portal\\deploy.sh":{"size":3276,"mtime_ns":1782335829420010800,"hash":"93155dc8f78a78f135a0687f64636e2bc09b9dd2d7e3f8a3ecf01ddf9aaeba38"},"H:\\IPFS-portal\\package.json":{"size":741,"mtime_ns":1782331693806062100,"hash":"b4d183d0a729b9f4854201b7a3e9a097b30f87d4f5fc666c2e5bebe5f26a3713"},"H:\\IPFS-portal\\tsconfig.json":{"size":768,"mtime_ns":1782305238016408400,"hash":"07d2ec4b6bf62550169c81f9ecd578fd6f3f30c40ae57eb4ee0d57f2dd440ed1"}}
-12
View File
@@ -1,12 +0,0 @@
{
"runs": [
{
"date": "2026-06-27T12:47:45.586275+00:00",
"input_tokens": 0,
"output_tokens": 0,
"files": 37
}
],
"total_input_tokens": 0,
"total_output_tokens": 0
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
-187
View File
@@ -1,187 +0,0 @@
{
"H:\\IPFS-portal\\deploy.sh": {
"mtime": 1782335829.4200108,
"ast_hash": "c2bab196ff38a17553535fe4f8bbe819",
"semantic_hash": "c2bab196ff38a17553535fe4f8bbe819"
},
"H:\\IPFS-portal\\next.config.ts": {
"mtime": 1782323679.7429354,
"ast_hash": "e4e1a2f09cef4c8973680aada3492e6a",
"semantic_hash": "e4e1a2f09cef4c8973680aada3492e6a"
},
"H:\\IPFS-portal\\package.json": {
"mtime": 1782331693.806062,
"ast_hash": "e27c51334fbc9da00b627bfb435c6f17",
"semantic_hash": "e27c51334fbc9da00b627bfb435c6f17"
},
"H:\\IPFS-portal\\playwright.config.ts": {
"mtime": 1782323985.4343338,
"ast_hash": "6e5fed3d80cb43667faa997526898061",
"semantic_hash": "6e5fed3d80cb43667faa997526898061"
},
"H:\\IPFS-portal\\postcss.config.mjs": {
"mtime": 1782310036.331215,
"ast_hash": "9a944fbda06979be39571bd9bd00b0d9",
"semantic_hash": "9a944fbda06979be39571bd9bd00b0d9"
},
"H:\\IPFS-portal\\serve-static.js": {
"mtime": 1782305384.6641164,
"ast_hash": "bfab69b103dde3ffb47c529b702039c4",
"semantic_hash": "bfab69b103dde3ffb47c529b702039c4"
},
"H:\\IPFS-portal\\src\\app\\admin\\payment\\page.tsx": {
"mtime": 1782329023.8102376,
"ast_hash": "5307f48b701258697597fe94b5556379",
"semantic_hash": "5307f48b701258697597fe94b5556379"
},
"H:\\IPFS-portal\\src\\app\\api\\[...path]\\route.ts": {
"mtime": 1782323547.7908971,
"ast_hash": "f689f8997ccd50c7bf0d288d64658730",
"semantic_hash": "f689f8997ccd50c7bf0d288d64658730"
},
"H:\\IPFS-portal\\src\\app\\api\\payment\\verify\\route.ts": {
"mtime": 1782327091.7359939,
"ast_hash": "5ad06957bde104f5a84f49207516c141",
"semantic_hash": "5ad06957bde104f5a84f49207516c141"
},
"H:\\IPFS-portal\\src\\app\\dashboard\\page.tsx": {
"mtime": 1782323579.6307354,
"ast_hash": "317a7db415a3fe21e9efcd2910a2b571",
"semantic_hash": "317a7db415a3fe21e9efcd2910a2b571"
},
"H:\\IPFS-portal\\src\\app\\explorer\\components\\Breadcrumbs.tsx": {
"mtime": 1782311813.8024487,
"ast_hash": "7a8663d6921b01d4c5c40a4711cb604a",
"semantic_hash": "7a8663d6921b01d4c5c40a4711cb604a"
},
"H:\\IPFS-portal\\src\\app\\explorer\\components\\CIDInput.tsx": {
"mtime": 1782311809.727791,
"ast_hash": "65f143b625b84f2f2e3173e5a501bbc3",
"semantic_hash": "65f143b625b84f2f2e3173e5a501bbc3"
},
"H:\\IPFS-portal\\src\\app\\explorer\\components\\DirectoryListing.tsx": {
"mtime": 1782311847.8061457,
"ast_hash": "6b5535d5f6a9c217c5258cf2237d2d0a",
"semantic_hash": "6b5535d5f6a9c217c5258cf2237d2d0a"
},
"H:\\IPFS-portal\\src\\app\\explorer\\components\\FileIcon.tsx": {
"mtime": 1782311811.8335142,
"ast_hash": "eb56c4a87f86c02072213372fde78c57",
"semantic_hash": "eb56c4a87f86c02072213372fde78c57"
},
"H:\\IPFS-portal\\src\\app\\explorer\\components\\FilePreview.tsx": {
"mtime": 1782311858.0255232,
"ast_hash": "cd32e1869bcc9d2daf246ca28331adf6",
"semantic_hash": "cd32e1869bcc9d2daf246ca28331adf6"
},
"H:\\IPFS-portal\\src\\app\\explorer\\components\\GatewayLink.tsx": {
"mtime": 1782311816.2048059,
"ast_hash": "75c99a0ad5cec3c4a95e9a1b7c629f01",
"semantic_hash": "75c99a0ad5cec3c4a95e9a1b7c629f01"
},
"H:\\IPFS-portal\\src\\app\\explorer\\components\\PinBadge.tsx": {
"mtime": 1782311860.326526,
"ast_hash": "a9cfeed551301ba64baae63ae61af663",
"semantic_hash": "a9cfeed551301ba64baae63ae61af663"
},
"H:\\IPFS-portal\\src\\app\\explorer\\page.tsx": {
"mtime": 1782311882.7185402,
"ast_hash": "54d9d6575bec7bb6d79bdfdd25ed2a91",
"semantic_hash": "54d9d6575bec7bb6d79bdfdd25ed2a91"
},
"H:\\IPFS-portal\\src\\app\\history\\page.tsx": {
"mtime": 1782331561.210468,
"ast_hash": "49dbc35a3ac3741f194a9b669f4a0a22",
"semantic_hash": "49dbc35a3ac3741f194a9b669f4a0a22"
},
"H:\\IPFS-portal\\src\\app\\ipns\\page.tsx": {
"mtime": 1782323444.911427,
"ast_hash": "c1df10e59827567593b103f4d2264cb1",
"semantic_hash": "c1df10e59827567593b103f4d2264cb1"
},
"H:\\IPFS-portal\\src\\app\\layout-portal.tsx": {
"mtime": 1782245653.0417998,
"ast_hash": "d9fc9f177e5a1a6b9b7d3c055a5195d7",
"semantic_hash": "d9fc9f177e5a1a6b9b7d3c055a5195d7"
},
"H:\\IPFS-portal\\src\\app\\layout.tsx": {
"mtime": 1782245634.5082266,
"ast_hash": "5a3014d8d3238f91d605ff08286e614b",
"semantic_hash": "5a3014d8d3238f91d605ff08286e614b"
},
"H:\\IPFS-portal\\src\\app\\page.tsx": {
"mtime": 1782310269.774857,
"ast_hash": "2a7b2f606087627eeb707c544fbf758e",
"semantic_hash": "2a7b2f606087627eeb707c544fbf758e"
},
"H:\\IPFS-portal\\src\\app\\peers\\page.tsx": {
"mtime": 1782245691.6038518,
"ast_hash": "611bdfac279b5c618f8e9463b560661e",
"semantic_hash": "611bdfac279b5c618f8e9463b560661e"
},
"H:\\IPFS-portal\\src\\app\\pins\\page.tsx": {
"mtime": 1782311813.3564186,
"ast_hash": "a5f2b0b8faa633501c87a6bc4750ef9c",
"semantic_hash": "a5f2b0b8faa633501c87a6bc4750ef9c"
},
"H:\\IPFS-portal\\src\\app\\settings\\page.tsx": {
"mtime": 1782335599.9904728,
"ast_hash": "6f75968e308d9c29703232ab04165e8f",
"semantic_hash": "6f75968e308d9c29703232ab04165e8f"
},
"H:\\IPFS-portal\\src\\app\\upload\\page.tsx": {
"mtime": 1782335637.3023176,
"ast_hash": "23a4087203579b7238d395b3f8c48959",
"semantic_hash": "23a4087203579b7238d395b3f8c48959"
},
"H:\\IPFS-portal\\src\\app\\users\\page.tsx": {
"mtime": 1782329529.5074713,
"ast_hash": "1e4c5ee26d4ce72bae3734bed5563ae4",
"semantic_hash": "1e4c5ee26d4ce72bae3734bed5563ae4"
},
"H:\\IPFS-portal\\src\\components\\PaymentPanel.tsx": {
"mtime": 1782325710.1937659,
"ast_hash": "041ebb060c47ebb6b0e6fb5be0ca0f2e",
"semantic_hash": "041ebb060c47ebb6b0e6fb5be0ca0f2e"
},
"H:\\IPFS-portal\\src\\components\\PortalHeader.tsx": {
"mtime": 1782245647.888308,
"ast_hash": "aa2056cf805a28927f51822d2acf1766",
"semantic_hash": "aa2056cf805a28927f51822d2acf1766"
},
"H:\\IPFS-portal\\src\\components\\PortalSidebar.tsx": {
"mtime": 1782331656.858291,
"ast_hash": "d6c679be20bffe1fa35419769c9f7496",
"semantic_hash": "d6c679be20bffe1fa35419769c9f7496"
},
"H:\\IPFS-portal\\src\\lib\\api.ts": {
"mtime": 1782329220.8041124,
"ast_hash": "7a394e3d0aa7f9cd76228bbe83b28137",
"semantic_hash": "7a394e3d0aa7f9cd76228bbe83b28137"
},
"H:\\IPFS-portal\\src\\lib\\payment.ts": {
"mtime": 1782329623.6892607,
"ast_hash": "b90cabcab2fde7b9ac67995822a22c01",
"semantic_hash": "b90cabcab2fde7b9ac67995822a22c01"
},
"H:\\IPFS-portal\\src\\lib\\storage.ts": {
"mtime": 1782331404.7951808,
"ast_hash": "3ad1d14d945bfb9d4febae0cb170ab37",
"semantic_hash": "3ad1d14d945bfb9d4febae0cb170ab37"
},
"H:\\IPFS-portal\\src\\lib\\wallet.ts": {
"mtime": 1782325663.3287177,
"ast_hash": "765b52a55f741e10f2c002d393e191c5",
"semantic_hash": "765b52a55f741e10f2c002d393e191c5"
},
"H:\\IPFS-portal\\tests\\portal.spec.ts": {
"mtime": 1782335736.5423336,
"ast_hash": "b637be3c8e2edfc09f7180a7a0247ebc",
"semantic_hash": "b637be3c8e2edfc09f7180a7a0247ebc"
},
"H:\\IPFS-portal\\tsconfig.json": {
"mtime": 1782305238.0164084,
"ast_hash": "bb6977ff03c7e4d3028e1af49008da2f",
"semantic_hash": "bb6977ff03c7e4d3028e1af49008da2f"
}
}
+25
View File
@@ -1,8 +1,33 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline'", // nodig voor Next.js
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://ipfs.maos.dedyn.io https://*.maos.dedyn.io",
"media-src 'self' https://ipfs.maos.dedyn.io https://*.maos.dedyn.io",
"connect-src 'self' ws: wss: ${process.env.NEXT_PUBLIC_ZKSYNC_RPC_URL || 'http://localhost:3050'}",
"frame-src 'self'",
"base-uri 'self'",
"form-action 'self'",
].join('; ');
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
images: { unoptimized: true }, images: { unoptimized: true },
output: process.env.DOCKER_BUILD ? 'standalone' : undefined, output: process.env.DOCKER_BUILD ? 'standalone' : undefined,
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'Content-Security-Policy', value: csp },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
],
},
];
},
}; };
export default nextConfig; export default nextConfig;
+1 -10
View File
@@ -1,10 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0891b2"/><text x="16" y="22" font-family="Arial" font-weight="bold" font-size="18" fill="white" text-anchor="middle">M</text></svg>
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#a855f7"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="6" fill="url(#g)"/>
<text x="16" y="22" text-anchor="middle" font-family="system-ui" font-weight="800" font-size="18" fill="white">M</text>
</svg>

Before

Width:  |  Height:  |  Size: 446 B

After

Width:  |  Height:  |  Size: 235 B

+3 -22
View File
@@ -1,31 +1,12 @@
{ {
"name": "MAOS IPFS Portal", "name": "MAOS IPFS Portal",
"short_name": "IPFS Portal", "short_name": "IPFS Portal",
"description": "Decentralized storage management for your IPFS node", "description": "Decentralized storage management for IPFS",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"background_color": "#0a0e17", "background_color": "#0f172a",
"theme_color": "#0891b2", "theme_color": "#0891b2",
"orientation": "any",
"categories": ["utilities", "productivity"],
"icons": [ "icons": [
{ { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
] ]
} }
+21 -135
View File
@@ -1,145 +1,31 @@
/* ── IPFS Portal Service Worker v3 ── /* ── IPFS Portal Service Worker ──
* * Minimal offline cache for static assets.
* Cache strategieën per route type: * Auto-generated — do not edit manually.
* - Precache: alleen publieke pages (auth-protected pages worden
* gecachet via stale-while-revalidate tijdens normaal gebruik)
* - Cache-first: static assets (fonts, images, CSS, JS)
* - Network-first: API calls (/_next/data, /api/*)
* - Stale-while-revalidate: navigatie requests
*
* Let op: auth-protected pages (dashboard, upload, settings, etc.)
* worden NIET geprecached — tijdens installatie is er geen sessie-
* cookie, dus de middleware redirect naar /login. Alleen publieke
* pagina's in de precache.
*/ */
const CACHE = 'ipfs-portal-v1';
const PRECACHE = ['/', '/manifest.json', '/favicon.svg'];
const CACHE = 'ipfs-portal-v4'; self.addEventListener('install', (e) => {
const STATIC_CACHE = 'ipfs-portal-static-v4'; e.waitUntil(
const DATA_CACHE = 'ipfs-portal-data-v4'; caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting()),
const PRECACHE_URLS = [
'/',
'/login',
];
/* ── Install: precache app shell ── */
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(PRECACHE_URLS))
); );
self.skipWaiting();
}); });
/* ── Activate: clean old caches ── */ self.addEventListener('activate', (e) => {
e.waitUntil(
self.addEventListener('activate', (event) => { caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))),
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((k) => k !== CACHE && k !== STATIC_CACHE && k !== DATA_CACHE)
.map((k) => caches.delete(k))
)
)
); );
self.clients.claim();
}); });
/* ── Helper: is this a navigation request? ── */ self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET') return;
function isNavigation(req) { e.respondWith(
return req.mode === 'navigate'; caches.match(e.request).then((cached) => cached || fetch(e.request).then((res) => {
} if (res.ok && res.url.startsWith(self.location.origin)) {
const clone = res.clone();
function isStaticAsset(url) { caches.open(CACHE).then((c) => c.put(e.request, clone));
return /\.(png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|eot|css|js|json)$/i.test(url.pathname);
}
function isAPIRequest(url) {
return url.pathname.startsWith('/api/') || url.pathname.startsWith('/_next/data/');
}
/* ── Fetch handler ── */
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Same-origin only
if (url.origin !== self.location.origin) return;
// Only cache GET/HEAD — POST/PUT/DELETE/OPTIONS can't use Cache API
if (request.method !== 'GET' && request.method !== 'HEAD') {
return; // pass through, no caching
} }
return res;
// 1. Static assets → cache-first })),
if (isStaticAsset(url)) { );
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
// 2. API / Next.js data → network-first
if (isAPIRequest(url)) {
event.respondWith(networkFirst(request, DATA_CACHE));
return;
}
// 3. Navigation → network-first (nooit stale HTML serveren, HMR breekt)
if (isNavigation(request)) {
event.respondWith(networkFirst(request, CACHE));
return;
}
// 4. All other requests → network-first with cache fallback
event.respondWith(networkFirst(request, CACHE));
}); });
/* ── Cache strategies ── */
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
// Cache API supports GET/HEAD only
if (response.ok && request.method === 'GET') {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch {
return new Response('Offline', { status: 503 });
}
}
async function networkFirst(request, cacheName) {
try {
const response = await fetch(request);
// Cache API supports GET/HEAD only — POST/PUT/DELETE must be skipped
if (response.ok && request.method === 'GET') {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
return new Response('Offline', { status: 503 });
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetchPromise = fetch(request)
.then((response) => {
if (response.ok && request.method === 'GET') cache.put(request, response.clone());
return response;
})
.catch(() => cached);
return cached || fetchPromise;
}
-26
View File
@@ -1,26 +0,0 @@
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:);
});
+5 -3
View File
@@ -3,7 +3,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import PortalLayout from '@/app/layout-portal'; import PortalLayout from '@/app/layout-portal';
import AuthGuard from '@/components/AuthGuard'; import AuthGuard from '@/components/AuthGuard';
import { listUsers, getNodeInfo, checkHealth, type IPFSNodeInfo } from '@/lib/api'; import { listUsers } from '@/lib/api/users';
import { getNodeInfo, checkHealth } from '@/lib/api/gateway';
import type { IPFSNodeInfo } from '@/lib/api/client';
import { SkeletonCard, SkeletonTable } from '@/components/Skeleton'; import { SkeletonCard, SkeletonTable } from '@/components/Skeleton';
import Link from 'next/link'; import Link from 'next/link';
import { import {
@@ -22,14 +24,14 @@ export default function AdminPage() {
useEffect(() => { useEffect(() => {
listUsers() listUsers()
.then(setUsers) .then(setUsers)
.catch(() => {}) .catch((err) => { console.error('[Admin] listUsers failed:', err); })
.finally(() => setLoadingUsers(false)); .finally(() => setLoadingUsers(false));
}, []); }, []);
useEffect(() => { useEffect(() => {
checkHealth() checkHealth()
.then((h: any) => setHealth(h)) .then((h: any) => setHealth(h))
.catch(() => {}) .catch((err) => { console.error('[Admin] checkHealth failed:', err); })
.finally(() => setLoadingHealth(false)); .finally(() => setLoadingHealth(false));
}, []); }, []);
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { Wallet, Loader2, CheckCircle, XCircle, AlertTriangle, LogOut } from "lucide-react";
import type { ViewMode, TxStatus } from "./helpers";
import { VIEW_LABELS } from "./helpers";
/* ── Wallet Connection ── */
interface WalletConnectProps {
connecting: boolean;
onConnect: () => void;
}
export function WalletConnect({ connecting, onConnect }: WalletConnectProps) {
return (
<div className="glass rounded-xl p-6 text-center space-y-4">
<Wallet className="w-8 h-8 text-surface-500 mx-auto" />
<p className="text-sm text-surface-400">Connect wallet to view admin panel</p>
<button onClick={onConnect} disabled={connecting}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
{connecting ? <><Loader2 className="w-4 h-4 animate-spin" /> Connecting...</> : <><Wallet className="w-4 h-4" /> Connect Wallet</>}
</button>
</div>
);
}
/* ── Wrong chain warning ── */
export function WrongChainWarning() {
return (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 text-sm mb-4">
<AlertTriangle className="w-4 h-4 shrink-0" />
Please switch to zkSync Local (chain 270)
</div>
);
}
/* ── Wallet Info Header ── */
interface WalletInfoProps {
address: string;
isOwner: boolean;
loading: boolean;
onRefresh: () => void;
onDisconnect: () => void;
}
export function WalletInfo({ address, isOwner, loading, onRefresh, onDisconnect }: WalletInfoProps) {
return (
<div className="flex items-center gap-3">
<span className="text-xs text-surface-400 hidden sm:inline font-mono">
{address.slice(0, 6)}...{address.slice(-4)}
</span>
<span className="text-xs text-surface-400 flex items-center gap-1.5">
<span className={`w-1.5 h-1.5 rounded-full ${isOwner ? "bg-accent-green" : "bg-surface-500"}`} />
{isOwner ? "Owner" : "Viewer"}
</span>
<button onClick={onDisconnect}
className="text-xs px-2.5 py-1.5 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-400 hover:text-red-400 transition-colors flex items-center gap-1"
>
<LogOut className="w-3 h-3" /> Disconnect
</button>
<button onClick={onRefresh} disabled={loading} className="p-2 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-400 transition-colors">
<Loader2 className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
);
}
/* ── Navigation Tabs ── */
interface NavTabsProps {
view: ViewMode;
onChange: (view: ViewMode) => void;
}
export function NavTabs({ view, onChange }: NavTabsProps) {
return (
<div className="flex gap-1 mb-6 p-1 rounded-lg bg-surface-800/50 w-fit">
{(Object.keys(VIEW_LABELS) as ViewMode[]).map((v) => (
<button key={v} onClick={() => onChange(v)}
className={`px-3 py-1.5 rounded-md text-xs font-medium capitalize transition-colors ${
view === v ? "bg-surface-700 text-white" : "text-surface-400 hover:text-surface-200"
}`}
>
{VIEW_LABELS[v]}
</button>
))}
</div>
);
}
/* ── TX Status Toast ── */
interface TxStatusToastProps {
status: TxStatus;
}
export function TxStatusToast({ status }: TxStatusToastProps) {
if (!status) return null;
return (
<div className={`fixed bottom-6 right-6 flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg text-sm animate-fade-in ${
status.ok
? "bg-accent-green/10 border border-accent-green/20 text-accent-green"
: "bg-accent-rose/10 border border-accent-rose/20 text-accent-rose"
}`}>
{status.ok ? <CheckCircle className="w-4 h-4 shrink-0" /> : <XCircle className="w-4 h-4 shrink-0" />}
{status.msg}
</div>
);
}
@@ -2,9 +2,10 @@
import { type Dispatch, type SetStateAction } from 'react'; import { type Dispatch, type SetStateAction } from 'react';
import { Settings } from 'lucide-react'; import { Settings } from 'lucide-react';
import { paymentService, type TokenInfo } from '@/lib/payment'; import { usePaymentService, type TokenInfo } from '@/lib/payment';
import { formatBytes } from '@/lib/helpers';
import type { WalletProvider } from '@/lib/wallet'; import type { WalletProvider } from '@/lib/wallet';
import { formatWeiToETH, formatBytes } from '@/lib/wallet'; import { formatWeiToETH } from '@/lib/wallet';
/* ── Props ── */ /* ── Props ── */
interface PricingViewProps { interface PricingViewProps {
@@ -29,6 +30,7 @@ export default function PricingView({
priceInput, setPriceInput, freeInput, setFreeInput, priceInput, setPriceInput, freeInput, setFreeInput,
doTx, provider, address, doTx, provider, address,
}: PricingViewProps) { }: PricingViewProps) {
const paymentService = usePaymentService();
return ( return (
<div className="max-w-2xl space-y-4"> <div className="max-w-2xl space-y-4">
@@ -2,7 +2,7 @@
import { type Dispatch, type SetStateAction } from 'react'; import { type Dispatch, type SetStateAction } from 'react';
import { Tags, Plus, Trash2 } from 'lucide-react'; import { Tags, Plus, Trash2 } from 'lucide-react';
import { paymentService, type MAOSDiscountTier } from '@/lib/payment'; import { usePaymentService, type MAOSDiscountTier } from '@/lib/payment';
import type { WalletProvider } from '@/lib/wallet'; import type { WalletProvider } from '@/lib/wallet';
import { formatWeiToETH } from '@/lib/wallet'; import { formatWeiToETH } from '@/lib/wallet';
@@ -25,6 +25,7 @@ export default function TiersView({
tierBalance, setTierBalance, tierBps, setTierBps, tierBalance, setTierBalance, tierBps, setTierBps,
doTx, provider, doTx, provider,
}: TiersViewProps) { }: TiersViewProps) {
const paymentService = usePaymentService();
return ( return (
<div className="max-w-2xl space-y-4"> <div className="max-w-2xl space-y-4">
@@ -2,7 +2,7 @@
import { type Dispatch, type SetStateAction } from 'react'; import { type Dispatch, type SetStateAction } from 'react';
import { Coins, PiggyBank } from 'lucide-react'; import { Coins, PiggyBank } from 'lucide-react';
import { paymentService, type TokenInfo } from '@/lib/payment'; import { usePaymentService, type TokenInfo } from '@/lib/payment';
import type { WalletProvider } from '@/lib/wallet'; import type { WalletProvider } from '@/lib/wallet';
import { formatWeiToETH } from '@/lib/wallet'; import { formatWeiToETH } from '@/lib/wallet';
@@ -34,6 +34,7 @@ export default function TokensView({
tokenDec, setTokenDec, tokenWei, setTokenWei, tokenDec, setTokenDec, tokenWei, setTokenWei,
tokenEnabled, setTokenEnabled, doTx, provider, address, tokenEnabled, setTokenEnabled, doTx, provider, address,
}: TokensViewProps) { }: TokensViewProps) {
const paymentService = usePaymentService();
return ( return (
<div className="max-w-2xl space-y-4"> <div className="max-w-2xl space-y-4">
+12
View File
@@ -0,0 +1,12 @@
/* ── Admin Payment Helpers ── */
export type ViewMode = "overview" | "pricing" | "tokens" | "tiers";
export type TxStatus = { ok: boolean; msg: string } | null;
export const VIEW_LABELS: Record<ViewMode, string> = {
overview: "Overview",
pricing: "Pricing",
tokens: "Tokens",
tiers: "Discount Tiers",
};
+61 -87
View File
@@ -3,22 +3,20 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import PortalLayout from '@/app/layout-portal'; import PortalLayout from '@/app/layout-portal';
import AuthGuard from '@/components/AuthGuard'; import AuthGuard from '@/components/AuthGuard';
import { paymentService, type TokenInfo, type MAOSDiscountTier } from '@/lib/payment'; import { paymentService, PaymentProvider, type TokenInfo, type MAOSDiscountTier } from '@/lib/payment';
import { import {
connectWallet, switchToChain270, getInjectedProvider, connectWallet, switchToChain270, getInjectedProvider, addWalletListener, disconnectWallet,
formatWeiToETH, formatBytes, type WalletProvider, type WalletProvider,
} from '@/lib/wallet'; } from '@/lib/wallet';
import { import { SkeletonCard } from '@/components/Skeleton';
Wallet, Loader2, CheckCircle, XCircle, import { useNotify } from '@/lib/notifications';
RefreshCw, AlertTriangle,
} from 'lucide-react';
import Overview from './components/Overview'; import Overview from './components/Overview';
import PricingView from './components/PricingView'; import PricingView from './components/PricingView';
import TokensView from './components/TokensView'; import TokensView from './components/TokensView';
import TiersView from './components/TiersView'; import TiersView from './components/TiersView';
import ErrorBoundary from '@/components/ErrorBoundary';
type ViewMode = 'overview' | 'pricing' | 'tokens' | 'tiers'; import type { ViewMode, TxStatus } from './helpers';
type TxStatus = { ok: boolean; msg: string } | null; import { WalletConnect, WrongChainWarning, WalletInfo, NavTabs, TxStatusToast } from './components';
export default function AdminPaymentPage() { export default function AdminPaymentPage() {
const [address, setAddress] = useState<string | null>(null); const [address, setAddress] = useState<string | null>(null);
@@ -42,13 +40,14 @@ export default function AdminPaymentPage() {
const [contractBalance, setContractBalance] = useState<Record<string, string>>({}); const [contractBalance, setContractBalance] = useState<Record<string, string>>({});
const isOwner = !!(address && owner && address.toLowerCase() === owner.toLowerCase()); const isOwner = !!(address && owner && address.toLowerCase() === owner.toLowerCase());
const { notify } = useNotify();
// ── Load all contract state ── // ── Load all contract state ──
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true); setLoading(true);
setTxStatus(null); setTxStatus(null);
try { try {
paymentService.isDeployed().then(setDeployed).catch(() => setDeployed(false)); paymentService.isDeployed().then(setDeployed).catch((err) => { console.error('[AdminPayment] Deploy check failed:', err); setDeployed(false); });
const [own, price, free, t, tiersData, uploads] = await Promise.all([ const [own, price, free, t, tiersData, uploads] = await Promise.all([
paymentService.getOwner(), paymentService.getOwner(),
paymentService.getBasePricePerMB(), paymentService.getBasePricePerMB(),
@@ -62,7 +61,6 @@ export default function AdminPaymentPage() {
setFreeTier(free); setFreeTier(free);
setTiers(tiersData); setTiers(tiersData);
// Deduplicate by symbol
const seen = new Set<string>(); const seen = new Set<string>();
const uniqueTokens = t.filter(tk => { const uniqueTokens = t.filter(tk => {
if (seen.has(tk.symbol)) return false; if (seen.has(tk.symbol)) return false;
@@ -72,32 +70,29 @@ export default function AdminPaymentPage() {
setTokens(uniqueTokens); setTokens(uniqueTokens);
const symbols = uniqueTokens.map(tk => tk.symbol); const symbols = uniqueTokens.map(tk => tk.symbol);
const revEntries = await Promise.all( const revEntries = await Promise.all(symbols.map(s => paymentService.getTotalRevenue(s)));
symbols.map(s => paymentService.getTotalRevenue(s))
);
const revMap: Record<string, bigint> = {}; const revMap: Record<string, bigint> = {};
symbols.forEach((s, i) => { revMap[s] = revEntries[i]; }); symbols.forEach((s, i) => { revMap[s] = revEntries[i]; });
setRevenues(revMap); setRevenues(revMap);
// Balances via RPC
const balMap: Record<string, string> = {}; const balMap: Record<string, string> = {};
for (const tk of t) { for (const tk of t) {
try { try {
const client = paymentService['getPublicClient'](); const client = (paymentService as any)['getPublicClient']();
if (tk.symbol === 'ETH') { if (tk.symbol === 'ETH') {
const bal = await client.getBalance({ address: paymentService['contractAddress'] }); const bal = await client.getBalance({ address: (paymentService as any)['contractAddress'] });
balMap['ETH'] = bal.toString(); balMap['ETH'] = bal.toString();
} else if (tk.address && tk.address !== '0x0000000000000000000000000000000000000000') { } 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 ERC20_BAL_ABI = [{ type: 'function' as const, name: 'balanceOf', inputs: [{ type: 'address' as const }], outputs: [{ type: 'uint256' as const }], stateMutability: 'view' as const }];
const bal = await client.readContract({ const bal = await client.readContract({
address: tk.address as `0x${string}`, address: tk.address as `0x${string}`,
abi: ERC20_BAL_ABI, abi: ERC20_BAL_ABI,
functionName: 'balanceOf', functionName: 'balanceOf',
args: [paymentService['contractAddress']], args: [(paymentService as any)['contractAddress']],
}); });
balMap[tk.symbol] = (bal as bigint).toString(); balMap[tk.symbol] = (bal as bigint).toString();
} }
} catch { /* skip */ } } catch (err) { console.error('[AdminPayment] Failed to fetch balance for token:', err); }
} }
setContractBalance(balMap); setContractBalance(balMap);
setTotalUploads(uploads); setTotalUploads(uploads);
@@ -111,6 +106,22 @@ export default function AdminPaymentPage() {
useEffect(() => { refresh(); }, [refresh]); useEffect(() => { refresh(); }, [refresh]);
// ── Listen for external wallet disconnect ──
useEffect(() => {
if (!provider) return;
const cb = (accounts: string[]) => {
if (accounts.length === 0) {
setAddress(null);
setProvider(null);
setConnecting(false);
setWrongChain(false);
setWalletType(null);
notify({ type: 'info', title: 'Wallet disconnected' });
}
};
addWalletListener('accountsChanged', cb);
}, [provider, notify]);
// ── Connect wallet ── // ── Connect wallet ──
async function handleConnect() { async function handleConnect() {
setConnecting(true); setConnecting(true);
@@ -129,14 +140,20 @@ export default function AdminPaymentPage() {
else if (window.ethereum?.isRabby) setWalletType('Rabby'); else if (window.ethereum?.isRabby) setWalletType('Rabby');
else if (window.ethereum?.isMetaMask) setWalletType('MetaMask'); else if (window.ethereum?.isMetaMask) setWalletType('MetaMask');
else setWalletType('Wallet'); else setWalletType('Wallet');
} catch (e: any) { } catch (e: unknown) {
setTxStatus({ ok: false, msg: e.message || 'Connect failed' }); setTxStatus({ ok: false, msg: e instanceof Error ? e.message : 'Connect failed' });
} finally { } finally {
setConnecting(false); setConnecting(false);
} }
} }
// ── Admin action helper ── function handleDisconnect() {
disconnectWallet();
setAddress(null); setProvider(null); setConnecting(false);
setWrongChain(false); setWalletType(null);
notify({ type: 'success', title: 'Wallet disconnected' });
}
async function doTx(label: string, fn: () => Promise<string>) { async function doTx(label: string, fn: () => Promise<string>) {
if (!provider) return; if (!provider) return;
setTxStatus(null); setTxStatus(null);
@@ -144,8 +161,8 @@ export default function AdminPaymentPage() {
const hash = await fn(); const hash = await fn();
setTxStatus({ ok: true, msg: `${label} success: ${hash.slice(0, 10)}...` }); setTxStatus({ ok: true, msg: `${label} success: ${hash.slice(0, 10)}...` });
await refresh(); await refresh();
} catch (e: any) { } catch (e: unknown) {
setTxStatus({ ok: false, msg: `${label} failed: ${e.message || e}` }); setTxStatus({ ok: false, msg: `${label} failed: ${e instanceof Error ? e.message : String(e)}` });
} }
} }
@@ -164,64 +181,33 @@ export default function AdminPaymentPage() {
return ( return (
<AuthGuard requireAdmin redirectTo="/dashboard"> <AuthGuard requireAdmin redirectTo="/dashboard">
<PortalLayout> <PortalLayout>
{/* ── Header ── */} <ErrorBoundary label="Betaalbeheer">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-2xl font-bold text-white">Payment Admin</h1> <h1 className="text-2xl font-bold text-white">Payment Admin</h1>
<p className="text-sm text-surface-400 mt-1">Manage IPFS Portal payment contract</p> <p className="text-sm text-surface-400 mt-1">Manage IPFS Portal payment contract</p>
</div> </div>
<div className="flex items-center gap-3">
{address && ( {address && (
<span className="text-xs text-surface-400 flex items-center gap-1.5"> <WalletInfo
<span className={`w-1.5 h-1.5 rounded-full ${isOwner ? 'bg-accent-green' : 'bg-surface-500'}`} /> address={address}
{isOwner ? 'Owner' : 'Viewer'} isOwner={isOwner}
</span> loading={loading}
onRefresh={refresh}
onDisconnect={handleDisconnect}
/>
)} )}
<button onClick={refresh} disabled={loading} className="p-2 rounded-lg bg-surface-800 hover:bg-surface-700 text-surface-400 transition-colors">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div> </div>
{/* ── Connect wallet ── */} {!address && <WalletConnect connecting={connecting} onConnect={handleConnect} />}
{!address && ( {wrongChain && <WrongChainWarning />}
<div className="glass rounded-xl p-6 text-center space-y-4">
<Wallet className="w-8 h-8 text-surface-500 mx-auto" />
<p className="text-sm text-surface-400">Connect wallet to view admin panel</p>
<button onClick={handleConnect} disabled={connecting}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
{connecting ? <><Loader2 className="w-4 h-4 animate-spin" /> Connecting...</> : <><Wallet className="w-4 h-4" /> Connect Wallet</>}
</button>
</div>
)}
{wrongChain && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 text-sm mb-4">
<AlertTriangle className="w-4 h-4 shrink-0" />
Please switch to zkSync Local (chain 270)
</div>
)}
{address && ( {address && (
<> <PaymentProvider>
{/* ── Nav tabs ── */} <NavTabs view={view} onChange={setView} />
<div className="flex gap-1 mb-6 p-1 rounded-lg bg-surface-800/50 w-fit">
{(['overview', 'pricing', 'tokens', 'tiers'] as ViewMode[]).map(v => (
<button key={v} onClick={() => setView(v)}
className={`px-3 py-1.5 rounded-md text-xs font-medium capitalize transition-colors ${
view === v ? 'bg-surface-700 text-white' : 'text-surface-400 hover:text-surface-200'
}`}
>
{v === 'overview' ? 'Overview' : v === 'pricing' ? 'Pricing' : v === 'tokens' ? 'Tokens' : 'Discount Tiers'}
</button>
))}
</div>
{/* ── Loading ── */}
{loading ? ( {loading ? (
<div className="flex items-center gap-2 text-surface-400 text-sm py-12 justify-center"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 animate-fade-in">
<Loader2 className="w-4 h-4 animate-spin" /> Loading contract state... {Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
</div> </div>
) : ( ) : (
<> <>
@@ -231,7 +217,7 @@ export default function AdminPaymentPage() {
tokens={tokens} tokens={tokens}
revenues={revenues} revenues={revenues}
contractBalance={contractBalance} contractBalance={contractBalance}
contractAddress={paymentService['contractAddress']} contractAddress={(paymentService as any)['contractAddress']}
owner={owner} owner={owner}
isOwner={isOwner} isOwner={isOwner}
address={address} address={address}
@@ -239,7 +225,6 @@ export default function AdminPaymentPage() {
onNavigate={(v: string) => setView(v as ViewMode)} onNavigate={(v: string) => setView(v as ViewMode)}
/> />
)} )}
{view === 'pricing' && ( {view === 'pricing' && (
<PricingView <PricingView
basePrice={basePrice} basePrice={basePrice}
@@ -256,7 +241,6 @@ export default function AdminPaymentPage() {
address={address} address={address}
/> />
)} )}
{view === 'tokens' && ( {view === 'tokens' && (
<TokensView <TokensView
tokens={tokens} tokens={tokens}
@@ -277,7 +261,6 @@ export default function AdminPaymentPage() {
address={address} address={address}
/> />
)} )}
{view === 'tiers' && ( {view === 'tiers' && (
<TiersView <TiersView
tiers={tiers} tiers={tiers}
@@ -293,19 +276,10 @@ export default function AdminPaymentPage() {
</> </>
)} )}
{/* ── TX Status ── */} <TxStatusToast status={txStatus} />
{txStatus && ( </PaymentProvider>
<div className={`fixed bottom-6 right-6 flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg text-sm animate-fade-in ${
txStatus.ok
? 'bg-accent-green/10 border border-accent-green/20 text-accent-green'
: 'bg-accent-rose/10 border border-accent-rose/20 text-accent-rose'
}`}>
{txStatus.ok ? <CheckCircle className="w-4 h-4 shrink-0" /> : <XCircle className="w-4 h-4 shrink-0" />}
{txStatus.msg}
</div>
)}
</>
)} )}
</ErrorBoundary>
</PortalLayout> </PortalLayout>
</AuthGuard> </AuthGuard>
); );
+11 -100
View File
@@ -3,85 +3,9 @@ import { describe, it, expect } from 'vitest';
/* ── Route matching & Kubo mapping tests ── /* ── Route matching & Kubo mapping tests ──
* *
* These test the pure functions from route.ts in isolation. * These test the pure functions from route.ts in isolation.
* We import the source directly since these functions have no * They're now exported so we import directly instead of duplicating logic.
* external dependencies (no fetch, no cookies, no process.env).
*/ */
import { matchRoute, mapToKuboAPI } from '../route';
// Inline import — vitest resolves @/ alias via vitest.config.ts
// We need to import the actual functions. Since they're not exported
// (they're module-private), we duplicate the logic here for testing.
// In a real project, consider exporting them for testability.
function matchRoute(path: string[], method: string): { target: string; path: string; method: string } | null {
if (!path || path.length === 0) return null;
const [segment, ...rest] = path;
switch (segment) {
case 'health':
return { target: 'health', path: '/health', method };
case 'auth':
return { target: 'auth', path: '/' + (rest.length > 0 ? rest.join('/') : ''), method };
case 'users':
return { target: 'users', path: '/users' + (rest.length > 0 ? '/' + rest.join('/') : ''), method };
case 'explorer':
case 'ipns':
return { target: 'ipfs', path: '/' + path.join('/'), method };
default:
return { target: 'ipfs', path: '/' + path.join('/'), method };
}
}
function mapToKuboAPI(path: string, method: string): string {
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';
}
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)}`;
}
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)}`;
}
if (p === 'repo/stats') return '/api/v0/repo/stat';
if (p === 'bw/stats') return '/api/v0/bw/stats';
return '/api/v0/' + p;
}
}
/* ════════════════════════ matchRoute ════════════════════════ */ /* ════════════════════════ matchRoute ════════════════════════ */
@@ -96,37 +20,24 @@ describe('matchRoute', () => {
expect(r?.path).toBe('/health'); expect(r?.path).toBe('/health');
}); });
it('routes auth', () => {
const r = matchRoute(['auth'], 'GET');
expect(r?.target).toBe('auth');
expect(r?.path).toBe('/');
});
it('routes auth/me', () => {
const r = matchRoute(['auth', 'me'], 'GET');
expect(r?.target).toBe('auth');
expect(r?.path).toBe('/me');
});
it('routes auth/login with POST', () => {
const r = matchRoute(['auth', 'login'], 'POST');
expect(r?.target).toBe('auth');
expect(r?.path).toBe('/login');
expect(r?.method).toBe('POST');
});
it('routes users', () => { it('routes users', () => {
const r = matchRoute(['users'], 'GET'); const r = matchRoute(['users'], 'GET');
expect(r?.target).toBe('users'); expect(r?.target).toBe('user');
expect(r?.path).toBe('/users'); expect(r?.path).toBe('/users');
}); });
it('routes users with subpath', () => { it('routes users with subpath', () => {
const r = matchRoute(['users', 'create'], 'POST'); const r = matchRoute(['users', 'create'], 'POST');
expect(r?.target).toBe('users'); expect(r?.target).toBe('user');
expect(r?.path).toBe('/users/create'); expect(r?.path).toBe('/users/create');
}); });
it('routes node/info to user API', () => {
const r = matchRoute(['node', 'info'], 'GET');
expect(r?.target).toBe('user');
expect(r?.path).toBe('/node/info');
});
it('routes explorer to ipfs', () => { it('routes explorer to ipfs', () => {
const r = matchRoute(['explorer', 'ls', 'QmTest'], 'GET'); const r = matchRoute(['explorer', 'ls', 'QmTest'], 'GET');
expect(r?.target).toBe('ipfs'); expect(r?.target).toBe('ipfs');
@@ -212,7 +123,7 @@ describe('mapToKuboAPI', () => {
}); });
it('maps bw/stats', () => { it('maps bw/stats', () => {
expect(mapToKuboAPI('bw/stats', 'GET')).toBe('/api/v0/bw/stats'); expect(mapToKuboAPI('bw/stats', 'GET')).toBe('/api/v0/bw');
}); });
it('falls through for unknown paths', () => { it('falls through for unknown paths', () => {
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { userApiBase, userApiAdminKey } from '@/lib/config';
export async function handleHealth(req: NextRequest): Promise<NextResponse> {
// Check User API reachability
let userApiOk = false;
let userApiMsg = 'unknown';
try {
const r = await fetch(`${userApiBase()}/users`, {
headers: { 'X-Admin-Key': userApiAdminKey() },
signal: AbortSignal.timeout(5000),
});
if (r.ok) {
userApiOk = true;
userApiMsg = 'connected';
} else {
userApiMsg = `status ${r.status}`;
}
} catch (e: unknown) {
userApiMsg = e instanceof Error ? e.message.substring(0, 60) : 'error';
}
// Check Kubo API reachability
let kuboApiOk = false;
let kuboApiMsg = 'not configured';
const kuboSvc = process.env.KUBO_API_URL;
const kuboAuth = process.env.KUBO_BASIC_AUTH;
if (kuboSvc) {
try {
const headers: Record<string, string> = {};
if (kuboAuth) {
headers['Authorization'] = 'Basic ' + Buffer.from(kuboAuth).toString('base64');
}
const r = await fetch(kuboSvc.replace(/\/+$/, '') + '/api/v0/version', {
method: 'POST',
headers,
signal: AbortSignal.timeout(5000),
});
if (r.ok) {
const data = await r.json();
kuboApiOk = true;
kuboApiMsg = `v${data.Version || 'unknown'}`;
} else {
kuboApiMsg = `status ${r.status}`;
}
} catch (e: unknown) {
kuboApiMsg = e instanceof Error ? 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',
});
}
export async function proxyResult(res: Response): Promise<NextResponse> {
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: {
'Content-Type': 'application/json',
},
});
}
+132
View File
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server';
import { kuboApiUrl, kuboBasicAuth } from '@/lib/config';
export async function proxyIPFS(path: string, method: string, req: NextRequest): Promise<Response> {
// In dev, the Kubo API is behind nginx with Basic Auth.
// If no KUBO_API_URL is configured, return a clear error.
const kuboSvc = kuboApiUrl();
if (!kuboSvc) {
return NextResponse.json(
{ error: 'IPFS proxy not configured', detail: 'Set KUBO_API_URL in env for dev, or use nginx routing in production' },
{ status: 501 }
);
}
// Map /api/explorer/ls/<cid> → /api/v0/ls?arg=<cid>, etc.
const url = new URL(kuboSvc);
const kuboPath = mapToKuboAPI(path, method);
// Split pathname and query string — url.pathname encodes `?` as `%3F`
const [namePart, ...qsParts] = kuboPath.split('?');
url.pathname = namePart;
if (qsParts.length > 0) {
url.search = qsParts.join('?');
} else {
// Forward incoming query params (used by ipns/publish etc.)
const incomingSearch = req.nextUrl.search;
if (incomingSearch) {
url.search = incomingSearch;
}
}
const headers: Record<string, string> = {};
const auth = kuboBasicAuth();
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 body = method === 'POST' || method === 'DELETE' ? req.body : null;
const fetchOpts: RequestInit & { duplex?: string } = {
method: 'POST',
headers,
signal: AbortSignal.timeout(30000),
};
// Node.js fetch requires duplex: 'half' when streaming a body
if (body) {
fetchOpts.duplex = 'half';
fetchOpts.body = body;
}
let res: Response;
try {
res = await fetch(url.toString(), fetchOpts);
} catch (fetchErr: unknown) {
const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
console.error('[proxyIPFS] fetch error:', msg);
return NextResponse.json(
{ error: 'IPFS proxy fetch failed', detail: msg.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',
},
});
}
export function mapToKuboAPI(path: string, method: string): string {
// Normalize: /api/node/info → /api/v0/id, etc.
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
switch (p) {
case 'node/info':
return '/api/v0/id';
case 'peers':
return '/api/v0/swarm/peers';
case 'pins':
return method === 'GET' ? '/api/v0/pin/ls' : '/api/v0/pin/add';
default:
if (p.startsWith('pins/')) {
const cid = p.slice(5);
return `/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`;
}
if (p === 'files/upload') {
return '/api/v0/add?pin=true';
}
if (p.startsWith('files/')) {
return '/api/v0/ls';
}
// Explorer routes: /explorer/ls/<cid>, /explorer/cat/<cid>, etc.
if (p.startsWith('explorer/ls/')) {
const cid = p.slice('explorer/ls/'.length);
return `/api/v0/ls?arg=${encodeURIComponent(cid)}`;
}
if (p.startsWith('explorer/cat/')) {
const cid = p.slice('explorer/cat/'.length);
return `/api/v0/cat?arg=${encodeURIComponent(cid)}`;
}
if (p.startsWith('explorer/stat/')) {
const cid = p.slice('explorer/stat/'.length);
return `/api/v0/object/stat?arg=${encodeURIComponent(cid)}`;
}
if (p.startsWith('explorer/resolve/')) {
const name = p.slice('explorer/resolve/'.length);
return `/api/v0/resolve?arg=${encodeURIComponent(name)}`;
}
// IPNS routes
if (p === 'ipns/publish') return '/api/v0/name/publish';
if (p === 'ipns/keys') return '/api/v0/key/list';
if (p.startsWith('ipns/resolve/')) {
const name = p.slice('ipns/resolve/'.length);
return `/api/v0/name/resolve?arg=${encodeURIComponent(name)}`;
}
if (p.startsWith('ipns/keys/gen/')) {
const name = p.slice('ipns/keys/gen/'.length);
return `/api/v0/key/gen?arg=${encodeURIComponent(name)}`;
}
// Dashboard routes
if (p === 'repo/stats') return '/api/v0/repo/stat';
if (p === 'bw/stats') return '/api/v0/bw';
return '/api/v0/' + p;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest } from 'next/server';
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
import { userApiBase, userApiAdminKey } from '@/lib/config';
async function getSessionToken(req: NextRequest): Promise<string | null> {
const token = req.cookies.get(SESSION_COOKIE)?.value;
if (!token) return null;
const session = await verifySessionJWT(token);
return session?.sessionToken ?? null;
}
export async function proxyUserAPI(path: string, method: string, body: ReadableStream<Uint8Array> | null, req: NextRequest): Promise<Response> {
const url = `${userApiBase()}${path}`;
const headers: Record<string, string> = {
'X-Admin-Key': userApiAdminKey(),
};
if (method === 'POST' || method === 'PUT') {
headers['Content-Type'] = 'application/json';
}
// Forward session token from JWT cookie to Python backend
const sessionToken = await getSessionToken(req);
if (sessionToken) {
headers['X-Session-Token'] = sessionToken;
}
return fetch(url, {
method,
headers,
body,
signal: AbortSignal.timeout(15000),
});
}
+7 -253
View File
@@ -11,24 +11,18 @@
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server'; import type { RouteMatch } from './types';
import { proxyUserAPI } from './proxy-user';
import { proxyIPFS } from './proxy-ipfs';
import { handleHealth, proxyResult } from './handlers';
export { mapToKuboAPI } from './proxy-ipfs';
export const dynamic = 'force-dynamic'; 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 ── */ /* ── Route matching ── */
interface RouteMatch { export function matchRoute(path: string[], method: string): RouteMatch | null {
target: 'user' | '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; if (!path || path.length === 0) return null;
const [segment, ...rest] = path; const [segment, ...rest] = path;
@@ -58,175 +52,6 @@ function matchRoute(path: string[], method: string): RouteMatch | null {
} }
} }
/* ── Extract session token from JWT cookie ── */
async function getSessionToken(req: NextRequest): Promise<string | null> {
const token = req.cookies.get(SESSION_COOKIE)?.value;
if (!token) return null;
const session = await verifySessionJWT(token);
return session?.sessionToken ?? null;
}
/* ── User API proxy ── */
async function proxyUserAPI(path: string, method: string, body: ReadableStream<Uint8Array> | null, req: NextRequest): Promise<Response> {
const url = `${USER_API_BASE}${path}`;
const headers: Record<string, string> = {
'X-Admin-Key': ADMIN_KEY,
};
if (method === 'POST' || method === 'PUT') {
headers['Content-Type'] = 'application/json';
}
// Forward session token from JWT cookie to Python backend
const sessionToken = await getSessionToken(req);
if (sessionToken) {
headers['X-Session-Token'] = sessionToken;
}
return fetch(url, {
method,
headers,
body,
signal: AbortSignal.timeout(15000),
});
}
/* ── IPFS Kubo proxy (via nginx) ── */
async function proxyIPFS(path: string, method: string, req: NextRequest): Promise<Response> {
// In dev, the Kubo API is behind nginx with Basic Auth.
// If no KUBO_API_URL is configured, return a clear error.
const kuboSvc = process.env.KUBO_API_URL || process.env.NEXT_PUBLIC_KUBO_API_URL;
if (!kuboSvc) {
return NextResponse.json(
{ error: 'IPFS proxy not configured', detail: 'Set KUBO_API_URL in env for dev, or use nginx routing in production' },
{ status: 501 }
);
}
// Map /api/explorer/ls/<cid> → /api/v0/ls?arg=<cid>, etc.
const url = new URL(kuboSvc);
const kuboPath = mapToKuboAPI(path, method);
// Split pathname and query string — url.pathname encodes `?` as `%3F`
const [namePart, ...qsParts] = kuboPath.split('?');
url.pathname = namePart;
if (qsParts.length > 0) {
url.search = qsParts.join('?');
} else {
// Forward incoming query params (used by ipns/publish etc.)
const incomingSearch = req.nextUrl.search;
if (incomingSearch) {
url.search = incomingSearch;
}
}
const headers: Record<string, string> = {};
const auth = process.env.KUBO_BASIC_AUTH;
if (auth) {
headers['Authorization'] = 'Basic ' + Buffer.from(auth).toString('base64');
}
// Forward Content-Type (preserves multipart boundary for file uploads)
const reqCt = req.headers.get('content-type');
if (reqCt) {
headers['Content-Type'] = reqCt;
}
// Kubo uses POST for everything
const fetchOpts: RequestInit & { duplex?: string } = {
method: 'POST',
headers,
signal: AbortSignal.timeout(30000),
};
// Node.js fetch requires duplex: 'half' when streaming a body
if (method === 'POST' && req.body) {
fetchOpts.duplex = 'half';
}
// Forward body for add/pin operations
if (method === 'POST' && req.body) {
fetchOpts.body = req.body;
}
let res: Response;
try {
res = await fetch(url.toString(), fetchOpts);
} catch (fetchErr: any) {
console.error('[proxyIPFS] fetch error:', fetchErr.message);
return NextResponse.json(
{ error: 'IPFS proxy fetch failed', detail: fetchErr.message?.substring(0, 200) },
{ status: 502 },
);
}
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: {
'Content-Type': res.headers.get('Content-Type') || 'application/json',
},
});
}
function mapToKuboAPI(path: string, method: string): string {
// Normalize: /api/node/info → /api/v0/id, etc.
const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
switch (p) {
case 'node/info':
return '/api/v0/id';
case 'peers':
return '/api/v0/swarm/peers';
case 'pins':
return method === 'GET' ? '/api/v0/pin/ls' : '/api/v0/pin/add';
default:
if (p.startsWith('pins/')) {
const cid = p.slice(5);
return `/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`;
}
if (p === 'files/upload') {
return '/api/v0/add?pin=true';
}
if (p.startsWith('files/')) {
return '/api/v0/ls';
}
// Explorer routes: /explorer/ls/<cid>, /explorer/cat/<cid>, etc.
if (p.startsWith('explorer/ls/')) {
const cid = p.slice('explorer/ls/'.length);
return `/api/v0/ls?arg=${encodeURIComponent(cid)}`;
}
if (p.startsWith('explorer/cat/')) {
const cid = p.slice('explorer/cat/'.length);
return `/api/v0/cat?arg=${encodeURIComponent(cid)}`;
}
if (p.startsWith('explorer/stat/')) {
const cid = p.slice('explorer/stat/'.length);
return `/api/v0/object/stat?arg=${encodeURIComponent(cid)}`;
}
if (p.startsWith('explorer/resolve/')) {
const name = p.slice('explorer/resolve/'.length);
return `/api/v0/resolve?arg=${encodeURIComponent(name)}`;
}
// IPNS routes
if (p === 'ipns/publish') return '/api/v0/name/publish';
if (p === 'ipns/keys') return '/api/v0/key/list';
if (p.startsWith('ipns/resolve/')) {
const name = p.slice('ipns/resolve/'.length);
return `/api/v0/name/resolve?arg=${encodeURIComponent(name)}`;
}
if (p.startsWith('ipns/keys/gen/')) {
const name = p.slice('ipns/keys/gen/'.length);
return `/api/v0/key/gen?arg=${encodeURIComponent(name)}`;
}
// Dashboard routes
if (p === 'repo/stats') return '/api/v0/repo/stat';
if (p === 'bw/stats') return '/api/v0/bw/stats';
return '/api/v0/' + p;
}
}
/* ── Main handler ── */ /* ── Main handler ── */
export async function GET( export async function GET(
@@ -282,74 +107,3 @@ export async function DELETE(
return NextResponse.json({ error: 'Method not allowed' }, { status: 405 }); return NextResponse.json({ error: 'Method not allowed' }, { status: 405 });
} }
} }
/* ── Health check ── */
async function handleHealth(req: NextRequest): Promise<NextResponse> {
// Check User API reachability
let userApiOk = false;
let userApiMsg = 'unknown';
try {
const r = await fetch(`${USER_API_BASE}/users`, {
headers: { 'X-Admin-Key': ADMIN_KEY },
signal: AbortSignal.timeout(5000),
});
if (r.ok) {
userApiOk = true;
userApiMsg = 'connected';
} else {
userApiMsg = `status ${r.status}`;
}
} catch (e: any) {
userApiMsg = e.message?.substring(0, 60) || 'error';
}
// Check Kubo API reachability
let kuboApiOk = false;
let kuboApiMsg = 'not configured';
const kuboSvc = process.env.KUBO_API_URL;
const kuboAuth = process.env.KUBO_BASIC_AUTH;
if (kuboSvc) {
try {
const headers: Record<string, string> = {};
if (kuboAuth) {
headers['Authorization'] = 'Basic ' + Buffer.from(kuboAuth).toString('base64');
}
const r = await fetch(kuboSvc.replace(/\/+$/, '') + '/api/v0/version', {
method: 'POST',
headers,
signal: AbortSignal.timeout(5000),
});
if (r.ok) {
const data = await r.json();
kuboApiOk = true;
kuboApiMsg = `v${data.Version || 'unknown'}`;
} else {
kuboApiMsg = `status ${r.status}`;
}
} catch (e: any) {
kuboApiMsg = e.message?.substring(0, 60) || 'error';
}
}
const overall = userApiOk || kuboApiOk ? 'ok' : 'degraded';
return NextResponse.json({
status: overall,
userApi: userApiMsg,
kuboApi: kuboApiMsg,
mode: process.env.KUBO_API_URL ? 'proxy' : 'minimal',
});
}
/* ── Helpers ── */
async function proxyResult(res: Response): Promise<NextResponse> {
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: {
'Content-Type': 'application/json',
},
});
}
+5
View File
@@ -0,0 +1,5 @@
export interface RouteMatch {
target: 'user' | 'ipfs' | 'health';
path: string; // path relative to the backend
method: string;
}
+5 -5
View File
@@ -5,11 +5,10 @@
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { userApiBase } from '@/lib/config';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const body = await req.json(); const body = await req.json();
@@ -19,7 +18,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Address required' }, { status: 400 }); return NextResponse.json({ error: 'Address required' }, { status: 400 });
} }
const res = await fetch(`${USER_API_BASE}/auth/challenge`, { const res = await fetch(`${userApiBase()}/auth/challenge`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: address.toLowerCase() }), body: JSON.stringify({ address: address.toLowerCase() }),
@@ -28,9 +27,10 @@ export async function POST(req: NextRequest) {
const data = await res.json(); const data = await res.json();
return NextResponse.json(data, { status: res.status }); return NextResponse.json(data, { status: res.status });
} catch (e: any) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return NextResponse.json( return NextResponse.json(
{ error: e.message || 'Challenge failed' }, { error: msg || 'Challenge failed' },
{ status: 500 }, { status: 500 },
); );
} }
+6 -6
View File
@@ -6,11 +6,10 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { createSessionJWT, cookieOptions } from '@/lib/auth-server'; import { createSessionJWT, cookieOptions } from '@/lib/auth-server';
import { userApiBase } from '@/lib/config';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const body = await req.json(); const body = await req.json();
@@ -24,7 +23,7 @@ export async function POST(req: NextRequest) {
} }
// Proxy login to Python User API // Proxy login to Python User API
const res = await fetch(`${USER_API_BASE}/auth/login`, { const res = await fetch(`${userApiBase()}/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -48,7 +47,7 @@ export async function POST(req: NextRequest) {
// Determine role — Python backend returns role info via /auth/verify // Determine role — Python backend returns role info via /auth/verify
let role: 'admin' | 'user' = 'user'; let role: 'admin' | 'user' = 'user';
try { try {
const verifyRes = await fetch(`${USER_API_BASE}/auth/verify`, { const verifyRes = await fetch(`${userApiBase()}/auth/verify`, {
headers: { 'X-Session-Token': pythonSessionToken }, headers: { 'X-Session-Token': pythonSessionToken },
signal: AbortSignal.timeout(3000), signal: AbortSignal.timeout(3000),
}); });
@@ -76,9 +75,10 @@ export async function POST(req: NextRequest) {
response.cookies.set('ipfs-portal-session', jwt, cookieOptions()); response.cookies.set('ipfs-portal-session', jwt, cookieOptions());
return response; return response;
} catch (e: any) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return NextResponse.json( return NextResponse.json(
{ error: e.message || 'Login failed' }, { error: msg || 'Login failed' },
{ status: 500 }, { status: 500 },
); );
} }
+6 -5
View File
@@ -1,16 +1,16 @@
/* ── POST /api/auth/logout ── /* ── POST /api/auth/logout ──
* *
* Logt uit bij Python backend en wist JWT cookie. * Logt uit bij Python backend en wist JWT cookie.
* Best-effort: als Python backend niet bereikbaar is, cookie wordt altijd gewist.
*/ */
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server'; import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
import { userApiBase } from '@/lib/config';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
export async function POST() { export async function POST() {
const cookieStore = await cookies(); const cookieStore = await cookies();
const jwt = cookieStore.get(SESSION_COOKIE)?.value; const jwt = cookieStore.get(SESSION_COOKIE)?.value;
@@ -27,16 +27,17 @@ export async function POST() {
// Notify Python backend (best effort) // Notify Python backend (best effort)
if (sessionToken) { if (sessionToken) {
try { try {
await fetch(`${USER_API_BASE}/auth/logout`, { await fetch(`${userApiBase()}/auth/logout`, {
method: 'POST', method: 'POST',
headers: { 'X-Session-Token': sessionToken }, headers: { 'X-Session-Token': sessionToken },
signal: AbortSignal.timeout(3000), signal: AbortSignal.timeout(3000),
}); });
} catch { } catch (e) {
// Non-critical console.warn('[logout] Python backend unreachable:', e);
} }
} }
// Wipe cookie regardless
const response = NextResponse.json({ authenticated: false }); const response = NextResponse.json({ authenticated: false });
response.cookies.set(SESSION_COOKIE, '', { response.cookies.set(SESSION_COOKIE, '', {
httpOnly: true, httpOnly: true,
+6 -7
View File
@@ -6,9 +6,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server'; import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
import { userApiBase, userApiAdminKey } from '@/lib/config';
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';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -32,9 +30,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Current and new password required' }, { status: 400 }); return NextResponse.json({ error: 'Current and new password required' }, { status: 400 });
} }
const res = await fetch(`${USER_API_BASE}/auth/password`, { const res = await fetch(`${userApiBase()}/auth/password`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Admin-Key': ADMIN_KEY }, headers: { 'Content-Type': 'application/json', 'X-Admin-Key': userApiAdminKey() },
body: JSON.stringify({ body: JSON.stringify({
address: session.address, address: session.address,
currentPassword, currentPassword,
@@ -52,7 +50,8 @@ export async function POST(req: NextRequest) {
} }
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (e: any) { } catch (e: unknown) {
return NextResponse.json({ error: e.message || 'Password change failed' }, { status: 500 }); const msg = e instanceof Error ? e.message : String(e);
return NextResponse.json({ error: msg || 'Password change failed' }, { status: 500 });
} }
} }
+8 -6
View File
@@ -9,11 +9,11 @@
* Alternatief voor WebSocket (werkt door proxy heen). * Alternatief voor WebSocket (werkt door proxy heen).
*/ */
import { kuboApiUrl, kuboBasicAuth } from '@/lib/config';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
const KUBO_API = process.env.KUBO_API_URL || process.env.NEXT_PUBLIC_KUBO_API_URL;
const KUBO_AUTH = process.env.KUBO_BASIC_AUTH;
const POLL_INTERVAL = 3_000; // 3 seconden const POLL_INTERVAL = 3_000; // 3 seconden
/* ── Helpers ── */ /* ── Helpers ── */
@@ -23,13 +23,15 @@ function encodeSSE(event: string, data: unknown): string {
} }
async function kuboFetch(path: string): Promise<any> { async function kuboFetch(path: string): Promise<any> {
if (!KUBO_API) return null; const api = kuboApiUrl();
if (!api) return null;
try { try {
const url = new URL(KUBO_API); const url = new URL(api);
url.pathname = path; url.pathname = path;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (KUBO_AUTH) { const auth = kuboBasicAuth();
headers['Authorization'] = 'Basic ' + Buffer.from(KUBO_AUTH).toString('base64'); if (auth) {
headers['Authorization'] = 'Basic ' + Buffer.from(auth).toString('base64');
} }
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
method: 'POST', method: 'POST',
+13 -17
View File
@@ -14,18 +14,12 @@
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { createPublicClient, http, type Address, type Hash, type Chain } from 'viem'; import { createPublicClient, http, type Address, type Hash } from 'viem';
import { IPFS_PORTAL_PAYMENT_ABI } from '@/lib/payment'; import { IPFS_PORTAL_PAYMENT_ABI, zkSyncLocal } from '@/lib/payment';
import { zkSyncRpcUrl, paymentContractAddress } from '@/lib/config';
const RPC_URL = process.env.ZKSYNC_RPC_URL || 'http://192.168.1.176:3050'; const RPC_URL = zkSyncRpcUrl();
const CONTRACT_ADDRESS = (process.env.PAYMENT_CONTRACT_ADDRESS || '0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe') as Address; const CONTRACT_ADDRESS = paymentContractAddress() 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({ const client = createPublicClient({
chain: zkSyncLocal, chain: zkSyncLocal,
@@ -117,11 +111,12 @@ export async function GET(req: NextRequest) {
})), })),
verified: matchingUpload !== null, verified: matchingUpload !== null,
}); });
} catch (err: any) { } catch (err: unknown) {
console.error('[payment/verify] Error:', err.message); const msg = err instanceof Error ? err.message : String(err);
console.error('[payment/verify] Error:', msg);
return NextResponse.json({ return NextResponse.json({
error: 'Verification failed', error: 'Verification failed',
detail: err.message?.substring(0, 200), detail: msg.substring(0, 200),
}, { status: 500 }); }, { status: 500 });
} }
} }
@@ -159,12 +154,13 @@ export async function POST(req: NextRequest) {
gasUsed: receipt.gasUsed?.toString(), gasUsed: receipt.gasUsed?.toString(),
effectiveGasPrice: receipt.effectiveGasPrice?.toString(), effectiveGasPrice: receipt.effectiveGasPrice?.toString(),
}); });
} catch (err: any) { } catch (err: unknown) {
console.error('[payment/verify/tx] Error:', err.message); const msg = err instanceof Error ? err.message : String(err);
console.error('[payment/verify/tx] Error:', msg);
return NextResponse.json({ return NextResponse.json({
confirmed: false, confirmed: false,
error: 'Verification failed', error: 'Verification failed',
detail: err.message?.substring(0, 200), detail: msg.substring(0, 200),
}, { status: 500 }); }, { status: 500 });
} }
} }
+19
View File
@@ -0,0 +1,19 @@
/* ── Dashboard Page Helpers ── */
import type { LucideIcon } from "lucide-react";
export interface StatCard {
label: string;
value: string | number;
icon: LucideIcon;
color: string;
bg: string;
}
export function formatRepoSize(bytes: number): string {
return (bytes / 1e9).toFixed(2);
}
export function formatBandwidthMb(bytes: number): string {
return (bytes / 1e6).toFixed(1);
}
+8 -3
View File
@@ -16,6 +16,9 @@ import {
Fingerprint, Fingerprint,
} from 'lucide-react'; } from 'lucide-react';
import ErrorBoundary from '@/components/ErrorBoundary';
import { formatRepoSize, formatBandwidthMb } from './helpers';
export default function DashboardPage() { export default function DashboardPage() {
const { data, isValidating } = useDashboard(); const { data, isValidating } = useDashboard();
const realtime = useRealtime(); const realtime = useRealtime();
@@ -39,9 +42,9 @@ export default function DashboardPage() {
const loading = isValidating && !data; const loading = isValidating && !data;
const repoSizeGb = repo ? (repo.repoSize / 1e9).toFixed(2) : '—'; const repoSizeGb = repo ? formatRepoSize(repo.repoSize) : '—';
const bwIn = bw ? (bw.totalIn / 1e6).toFixed(1) : '—'; const bwIn = bw ? formatBandwidthMb(bw.totalIn) : '—';
const bwOut = bw ? (bw.totalOut / 1e6).toFixed(1) : '—'; const bwOut = bw ? formatBandwidthMb(bw.totalOut) : '—';
// Peer count from SSE (live) or SWR // Peer count from SSE (live) or SWR
const peerCount = realtime.peers?.count ?? peers.length; const peerCount = realtime.peers?.count ?? peers.length;
@@ -57,6 +60,7 @@ export default function DashboardPage() {
return ( return (
<PortalLayout> <PortalLayout>
<ErrorBoundary label="Dashboard">
{/* Page header */} {/* Page header */}
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex items-center justify-between">
<div> <div>
@@ -258,6 +262,7 @@ export default function DashboardPage() {
)} )}
</div> </div>
</div> </div>
</ErrorBoundary>
</PortalLayout> </PortalLayout>
); );
} }
@@ -1,8 +1,7 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import type { IPFSEntry } from '@/lib/api'; import { explorerLs, type IPFSEntry } from '@/lib/api/explorer';
import { explorerLs } from '@/lib/api';
import FileIcon from './FileIcon'; import FileIcon from './FileIcon';
import { ChevronRight, CheckCircle, Download, Loader2 } from 'lucide-react'; import { ChevronRight, CheckCircle, Download, Loader2 } from 'lucide-react';
import { truncateCid } from '@/lib/helpers'; import { truncateCid } from '@/lib/helpers';
@@ -221,7 +220,8 @@ export default function DirectoryListing({
try { try {
const result = await explorerLs(cid); const result = await explorerLs(cid);
setSubdirEntries((e) => ({ ...e, [cid]: result })); setSubdirEntries((e) => ({ ...e, [cid]: result }));
} catch { } catch (err) {
console.error('[DirectoryListing] Failed to list subdirectory:', err);
setSubdirEntries((e) => ({ ...e, [cid]: [] })); setSubdirEntries((e) => ({ ...e, [cid]: [] }));
} finally { } finally {
setLoadingSubdirs((l) => ({ ...l, [cid]: false })); setLoadingSubdirs((l) => ({ ...l, [cid]: false }));
+12 -10
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { X, Download, Maximize2 } from 'lucide-react'; import { X, Download, Maximize2 } from 'lucide-react';
import { getSettings } from '@/lib/storage'; import { getSettings } from '@/lib/storage';
import { gatewayUrl } from '@/lib/helpers';
interface FilePreviewProps { interface FilePreviewProps {
cid: string; cid: string;
@@ -12,11 +13,6 @@ interface FilePreviewProps {
filename?: string; filename?: string;
} }
function gatewayUrl(cid: string): string {
const { gatewayUrl: base } = getSettings();
return `${base}/${cid}`;
}
function detectFileType( function detectFileType(
filename: string | undefined, filename: string | undefined,
): 'image' | 'markdown' | 'json' | 'text' | 'video' | 'audio' | 'pdf' | 'html' | 'other' { ): 'image' | 'markdown' | 'json' | 'text' | 'video' | 'audio' | 'pdf' | 'html' | 'other' {
@@ -60,7 +56,8 @@ function renderJson(text: string): string {
const parsed = JSON.parse(text); const parsed = JSON.parse(text);
const syntax = JSON.stringify(parsed, null, 2); const syntax = JSON.stringify(parsed, null, 2);
return syntax; return syntax;
} catch { } catch (err) {
console.error('[FilePreview] JSON parse failed in renderJson:', err);
return text; return text;
} }
} }
@@ -69,13 +66,18 @@ function JsonDisplay({ text }: { text: string }) {
let parsed: unknown; let parsed: unknown;
try { try {
parsed = JSON.parse(text); parsed = JSON.parse(text);
} catch { } catch (err) {
console.error('[FilePreview] JSON parse failed in JsonDisplay:', err);
return <pre className="text-sm font-mono whitespace-pre-wrap text-surface-300">{text}</pre>; return <pre className="text-sm font-mono whitespace-pre-wrap text-surface-300">{text}</pre>;
} }
const formatted = JSON.stringify(parsed, null, 2); const formatted = JSON.stringify(parsed, null, 2);
const escaped = formatted
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const colored = formatted.replace( const colored = escaped.replace(
/("(?:\\.|[^"\\])*")\s*:/g, /("(?:\\.|[^"\\])*")\s*:/g,
'<span class="text-accent-cyan">$1</span>:', '<span class="text-accent-cyan">$1</span>:',
).replace( ).replace(
@@ -205,7 +207,7 @@ export default function FilePreview({ cid, content, loading, onClose, filename }
if (content) { if (content) {
return ( return (
<iframe <iframe
sandbox="allow-same-origin" sandbox="allow-scripts"
className={`w-full ${fullScreen ? 'h-[80vh]' : 'h-96'} rounded-lg bg-white`} className={`w-full ${fullScreen ? 'h-[80vh]' : 'h-96'} rounded-lg bg-white`}
srcDoc={content} srcDoc={content}
title="HTML preview" title="HTML preview"
@@ -214,7 +216,7 @@ export default function FilePreview({ cid, content, loading, onClose, filename }
} }
return ( return (
<iframe <iframe
sandbox="allow-same-origin" sandbox="allow-scripts"
className={`w-full ${fullScreen ? 'h-[80vh]' : 'h-96'} rounded-lg bg-white`} className={`w-full ${fullScreen ? 'h-[80vh]' : 'h-96'} rounded-lg bg-white`}
src={gatewayUrl(cid)} src={gatewayUrl(cid)}
title="HTML preview" title="HTML preview"
+2 -2
View File
@@ -17,8 +17,8 @@ export default function GatewayLink({ cid, filename }: GatewayLinkProps) {
await navigator.clipboard.writeText(gatewayUrl); await navigator.clipboard.writeText(gatewayUrl);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch { } catch (err) {
// clipboard write failed silently console.error('[GatewayLink] Clipboard write failed:', err);
} }
} }
+3 -3
View File
@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { CheckCircle, Pin } from 'lucide-react'; import { CheckCircle, Pin } from 'lucide-react';
import { addPin } from '@/lib/api'; import { addPin } from '@/lib/api/pins';
interface PinBadgeProps { interface PinBadgeProps {
cid: string; cid: string;
@@ -18,8 +18,8 @@ export default function PinBadge({ cid, isPinned, onPin }: PinBadgeProps) {
try { try {
await addPin(cid); await addPin(cid);
onPin(cid); onPin(cid);
} catch { } catch (err) {
// pin failed silently console.error('[PinBadge] Failed to pin CID:', err);
} finally { } finally {
setPinning(false); setPinning(false);
} }
+37
View File
@@ -0,0 +1,37 @@
/* ── Explorer Page Helpers ── */
import type { IPFSEntry } from "@/lib/api/explorer";
export const RECENT_STORAGE_KEY = "ipfs-explorer-recent-cids";
export interface BreadcrumbSegment {
cid: string;
name: string;
}
export type ExplorerState = "idle" | "loading" | "resolving" | "loaded" | "error";
export function loadRecentCids(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
return raw ? (JSON.parse(raw) as string[]) : [];
} catch (err) {
console.error("[ExplorerPage] Failed to load recent CIDs from localStorage:", err);
return [];
}
}
export 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 (err) {
console.error("[ExplorerPage] Failed to save recent CID to localStorage:", err);
}
}
export function findFilename(entries: IPFSEntry[], hash: string): string | undefined {
return entries.find((e) => e.hash === hash)?.name;
}
+28 -39
View File
@@ -2,47 +2,23 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import PortalLayout from '@/app/layout-portal'; import PortalLayout from '@/app/layout-portal';
import { explorerLs, explorerCat, listPins } from '@/lib/api'; import { explorerLs, explorerCat, type IPFSEntry } from '@/lib/api/explorer';
import type { IPFSEntry } from '@/lib/api'; import { listPins } from '@/lib/api/pins';
import CIDInput from './components/CIDInput'; import CIDInput from './components/CIDInput';
import DirectoryListing from './components/DirectoryListing'; import DirectoryListing from './components/DirectoryListing';
import FilePreview from './components/FilePreview'; import FilePreview from './components/FilePreview';
import Breadcrumbs from './components/Breadcrumbs'; import Breadcrumbs from './components/Breadcrumbs';
import PinBadge from './components/PinBadge'; import PinBadge from './components/PinBadge';
import GatewayLink from './components/GatewayLink'; import GatewayLink from './components/GatewayLink';
import SearchBar from '@/components/SearchBar'; import SearchBar from '@/components/search';
import type { SearchResult } from '@/lib/search-index'; import type { SearchResult } from '@/lib/search-index';
import { isTextFile, extractText, addToIndex, clearIndex, getIndexStats } from '@/lib/search-index'; import { isTextFile, extractText, addToIndex, clearIndex, getIndexStats } from '@/lib/search-index';
import { Search, AlertCircle, RefreshCw, Database, Loader2 } from 'lucide-react'; import { Search, AlertCircle, RefreshCw, Database, Loader2 } from 'lucide-react';
import Skeleton from '@/components/Skeleton';
const RECENT_STORAGE_KEY = 'ipfs-explorer-recent-cids'; import ErrorBoundary from '@/components/ErrorBoundary';
interface BreadcrumbSegment { import { loadRecentCids, saveRecentCid, findFilename, type ExplorerState, type BreadcrumbSegment } from './helpers';
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() { export default function ExplorerPage() {
const [cid, setCid] = useState(''); const [cid, setCid] = useState('');
@@ -111,7 +87,8 @@ export default function ExplorerPage() {
try { try {
const content = await explorerCat(hash); const content = await explorerCat(hash);
setPreviewContent(content); setPreviewContent(content);
} catch { } catch (err) {
console.error('[ExplorerPage] Failed to fetch preview content:', err);
setPreviewContent(null); setPreviewContent(null);
} finally { } finally {
setPreviewLoading(false); setPreviewLoading(false);
@@ -156,7 +133,8 @@ export default function ExplorerPage() {
}); });
indexed++; indexed++;
} }
} catch { } catch (err) {
console.error('[ExplorerPage] Failed to index pin:', err);
failed++; failed++;
} }
} }
@@ -171,6 +149,7 @@ export default function ExplorerPage() {
return ( return (
<PortalLayout> <PortalLayout>
<ErrorBoundary label="Verkenner">
{/* Page header */} {/* Page header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-white">IPFS Explorer</h1> <h1 className="text-2xl font-bold text-white">IPFS Explorer</h1>
@@ -234,11 +213,22 @@ export default function ExplorerPage() {
</div> </div>
)} )}
{/* Resolving IPNS */} {/* Resolving IPNS — skeleton */}
{state === 'resolving' && ( {state === 'resolving' && (
<div className="glass rounded-xl p-8 flex flex-col items-center justify-center text-center animate-fade-in"> <div className="glass rounded-xl p-8 animate-fade-in">
<RefreshCw className="w-5 h-5 text-accent-cyan animate-spin mb-3" /> <div className="flex items-center gap-3 mb-6">
<p className="text-sm text-surface-400">Resolving IPNS name...</p> <Skeleton className="h-5 w-48" />
<Skeleton className="h-5 w-28" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="glass rounded-xl p-5 space-y-3">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-7 w-48" />
<Skeleton className="h-2 w-full" />
</div>
))}
</div>
</div> </div>
)} )}
@@ -300,10 +290,9 @@ export default function ExplorerPage() {
)} )}
</div> </div>
)} )}
</ErrorBoundary>
</PortalLayout> </PortalLayout>
); );
} }
function findFilename(entries: IPFSEntry[], hash: string): string | undefined {
return entries.find((e) => e.hash === hash)?.name;
}
+15
View File
@@ -138,3 +138,18 @@ html.theme-transitioning *::after {
.animate-fade-in { animation: fade-in 0.4s ease-out both; } .animate-fade-in { animation: fade-in 0.4s ease-out both; }
.animate-pulse-glow { animation: pulse-glow 2s ease-in-out infinite; } .animate-pulse-glow { animation: pulse-glow 2s ease-in-out infinite; }
.animate-slide-up { animation: slide-up 0.3s ease-out both; } .animate-slide-up { animation: slide-up 0.3s ease-out both; }
/* ── Button variants (used in IPNS page) ── */
.btn-primary {
display: inline-flex; align-items: center; gap: 0.5rem;
background: var(--color-brand-600);
color: white;
font-weight: 500;
transition: background-color 0.15s;
}
.btn-primary:hover { background: var(--color-brand-500); }
.btn-ghost {
display: inline-flex; align-items: center;
transition: color 0.15s;
}
+374
View File
@@ -0,0 +1,374 @@
"use client";
import type { UploadRecord } from "@/lib/storage";
import { formatBytes, truncateCid, gatewayLink } from "@/lib/helpers";
import { downloadFile } from "@/lib/download";
import { SkeletonTable } from "@/components/Skeleton";
import Link from "next/link";
import {
Clock, HardDrive, Copy, ExternalLink, Trash2, Search, Upload,
Database, Calendar, Filter, X, CheckCircle, Download,
Link as LinkIcon,
} from "lucide-react";
import { getMethodBadge, recordKey } from "./helpers";
import BatchBar from "@/components/BatchBar";
/* ── Header ── */
interface HistoryHeaderProps {
totalUploads: number;
totalSize: number;
uniqueCids: number;
onClear: () => void;
}
export function HistoryHeader({ totalUploads, totalSize, uniqueCids, onClear }: HistoryHeaderProps) {
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Upload History</h1>
<p className="text-sm text-surface-400 mt-1">
{totalUploads > 0
? `${totalUploads} upload${totalUploads !== 1 ? "s" : ""} · ${formatBytes(totalSize)} total · ${uniqueCids} unique file${uniqueCids !== 1 ? "s" : ""}`
: "Track your past uploads"}
</p>
</div>
{totalUploads > 0 && (
<button
onClick={onClear}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent-rose/10 text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors self-start"
>
<Trash2 className="w-4 h-4" />
Clear History
</button>
)}
</div>
);
}
/* ── Search Bar ── */
interface HistorySearchProps {
search: string;
onChange: (value: string) => void;
}
export function HistorySearch({ search, onChange }: HistorySearchProps) {
return (
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500" />
<input
value={search}
onChange={(e) => onChange(e.target.value)}
placeholder="Search by file name or CID…"
className="w-full pl-10 pr-10 py-2 rounded-lg bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500"
/>
{search && (
<button
onClick={() => onChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-700 transition-colors"
>
<X className="w-4 h-4 text-surface-500" />
</button>
)}
</div>
);
}
/* ── Loading Skeleton ── */
export function HistorySkeleton() {
return (
<div className="glass rounded-xl overflow-hidden">
<div className="px-1">
<SkeletonTable rows={8} cols={3} />
</div>
</div>
);
}
/* ── Empty State ── */
export function HistoryEmpty() {
return (
<div className="glass rounded-xl p-12 text-center animate-fade-in">
<div className="flex justify-center mb-4">
<div className="p-3 rounded-xl bg-surface-800/50">
<Clock className="w-8 h-8 text-surface-500" />
</div>
</div>
<h3 className="text-lg font-semibold text-surface-300 mb-2">No upload history yet</h3>
<p className="text-sm text-surface-500 mb-6 max-w-md mx-auto">
Your uploaded files will appear here so you can easily copy CIDs, download files, or check upload details.
</p>
<Link
href="/upload"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand-600 text-white text-sm font-medium hover:bg-brand-500 transition-colors"
>
<Upload className="w-4 h-4" />
Upload your first file
</Link>
</div>
);
}
/* ── Desktop Table ── */
interface HistoryTableProps {
records: UploadRecord[];
selected: Set<string>;
copied: string | null;
onToggle: (key: string) => void;
onToggleAll: () => void;
allSelected: boolean;
onCopy: (text: string, key: string) => void;
}
export function HistoryTable({ records, selected, copied, onToggle, onToggleAll, allSelected, onCopy }: HistoryTableProps) {
return (
<div className="hidden sm:block glass rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-surface-800 text-left text-xs font-medium text-surface-500 uppercase tracking-wider">
<th className="px-5 py-3 w-10">
<input
type="checkbox"
checked={allSelected}
onChange={onToggleAll}
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500"
/>
</th>
<th className="px-5 py-3">File Name</th>
<th className="px-5 py-3">CID</th>
<th className="px-5 py-3">Size</th>
<th className="px-5 py-3">Date</th>
<th className="px-5 py-3">Method</th>
<th className="px-5 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-800">
{records.map((rec) => {
const badge = getMethodBadge(rec.method);
const gwLink = gatewayLink(rec.cid);
return (
<tr key={rec.cid + rec.date} className="hover:bg-surface-800/30 transition-colors">
<td className="px-5 py-3">
<input
type="checkbox"
checked={selected.has(recordKey(rec))}
onChange={() => onToggle(recordKey(rec))}
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500"
/>
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
<HardDrive className="w-3.5 h-3.5 text-surface-500 shrink-0" />
<span className="text-sm text-surface-200 truncate max-w-[200px]" title={rec.name}>
{rec.name}
</span>
</div>
</td>
<td className="px-5 py-3">
<span className="text-xs font-mono text-surface-400 cursor-default" title={rec.cid}>
{truncateCid(rec.cid)}
</span>
</td>
<td className="px-5 py-3">
<span className="text-xs text-surface-400">{formatBytes(rec.size)}</span>
</td>
<td className="px-5 py-3">
<div className="text-xs text-surface-400">
<div>{new Date(rec.date).toLocaleDateString()}</div>
<div className="text-surface-500">{new Date(rec.date).toLocaleTimeString()}</div>
</div>
</td>
<td className="px-5 py-3">
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => onCopy(rec.cid, `cid-${rec.cid}`)}
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
title="Copy CID"
>
{copied === `cid-${rec.cid}` ? (
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
) : (
<Copy className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
)}
</button>
<button
onClick={() => onCopy(gwLink, `gw-${rec.cid}`)}
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
title="Copy gateway link"
>
{copied === `gw-${rec.cid}` ? (
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
) : (
<LinkIcon className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
)}
</button>
<button
onClick={() => downloadFile(rec.cid, rec.name).catch((err) => { console.error("[HistoryPage] Download failed:", err); })}
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
title="Download file"
>
<Download className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
</button>
<a
href={gwLink}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
title="Open in gateway"
>
<ExternalLink className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
</a>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/* ── Mobile Card ── */
interface HistoryMobileCardProps {
rec: UploadRecord;
selected: Set<string>;
copied: string | null;
onToggle: (key: string) => void;
onCopy: (text: string, key: string) => void;
}
export function HistoryMobileCard({ rec, selected, copied, onToggle, onCopy }: HistoryMobileCardProps) {
const badge = getMethodBadge(rec.method);
const gwLink = gatewayLink(rec.cid);
return (
<div className="glass rounded-xl p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<input
type="checkbox"
checked={selected.has(recordKey(rec))}
onChange={() => onToggle(recordKey(rec))}
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500 shrink-0 mt-0.5"
/>
<HardDrive className="w-4 h-4 text-surface-500 shrink-0" />
<span className="text-sm text-surface-200 truncate" title={rec.name}>
{rec.name}
</span>
</div>
<span className={`shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-surface-400 truncate" title={rec.cid}>
{truncateCid(rec.cid)}
</span>
<button
onClick={() => onCopy(rec.cid, `cid-${rec.cid}`)}
className="p-1 rounded-lg hover:bg-surface-700 transition-colors shrink-0"
>
{copied === `cid-${rec.cid}` ? (
<CheckCircle className="w-3 h-3 text-accent-green" />
) : (
<Copy className="w-3 h-3 text-surface-500" />
)}
</button>
</div>
<div className="flex items-center gap-4 text-xs text-surface-500">
<span>{formatBytes(rec.size)}</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(rec.date).toLocaleDateString()}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(rec.date).toLocaleTimeString()}
</span>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => onCopy(rec.cid, `cid-m-${rec.cid}`)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
<Copy className="w-3 h-3" />
Copy CID
</button>
<button
onClick={() => onCopy(gwLink, `gw-m-${rec.cid}`)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
<LinkIcon className="w-3 h-3" />
Copy Link
</button>
<button
onClick={() => downloadFile(rec.cid, rec.name).catch((err) => { console.error("[HistoryPage] Download failed:", err); })}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
<Download className="w-3 h-3" />
Download
</button>
<a
href={gwLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
<ExternalLink className="w-3 h-3" />
Open
</a>
</div>
</div>
);
}
/* ── No Results ── */
interface HistoryNoResultsProps {
search: string;
onClear: () => void;
}
export function HistoryNoResults({ search, onClear }: HistoryNoResultsProps) {
return (
<div className="glass rounded-xl p-12 text-center">
<div className="flex justify-center mb-3">
<Search className="w-8 h-8 text-surface-500" />
</div>
<h3 className="text-base font-semibold text-surface-300 mb-1">No results found</h3>
<p className="text-sm text-surface-500">
No uploads match &ldquo;{search}&rdquo;
</p>
<button
onClick={onClear}
className="mt-4 text-sm text-brand-400 hover:text-brand-300 underline transition-colors"
>
Clear search
</button>
</div>
);
}
/* ── Results Count ── */
interface HistoryResultsCountProps {
filtered: number;
total: number;
onClear: () => void;
}
export function HistoryResultsCount({ filtered, total, onClear }: HistoryResultsCountProps) {
return (
<div className="mt-3 text-center text-xs text-surface-500">
Showing {filtered} of {total} uploads
<button
onClick={onClear}
className="ml-2 text-brand-400 hover:text-brand-300 underline transition-colors"
>
Clear filter
</button>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
/* ── History Page Helpers ── */
import type { UploadRecord } from "@/lib/storage";
export function recordKey(r: UploadRecord) {
return `${r.cid}||${r.date}`;
}
export 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" };
}
}
export function parseRecordKey(key: string): { cid: string; date: string } {
const idx = key.lastIndexOf("||");
return { cid: key.slice(0, idx), date: key.slice(idx + 2) };
}
+49 -355
View File
@@ -2,28 +2,22 @@
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { getHistory, clearHistory, removeMultipleFromHistory, type UploadRecord } from '@/lib/storage'; import { getHistory, clearHistory, removeMultipleFromHistory, type UploadRecord } from '@/lib/storage';
import { formatBytes, gatewayLink, truncateCid } from '@/lib/helpers';
import { downloadFile } from '@/lib/download';
import { useNotify } from '@/lib/notifications'; import { useNotify } from '@/lib/notifications';
import { SkeletonTable } from '@/components/Skeleton';
import BatchBar from '@/components/BatchBar';
import ErrorBoundary from '@/components/ErrorBoundary'; import ErrorBoundary from '@/components/ErrorBoundary';
import PortalLayout from '@/app/layout-portal'; import PortalLayout from '@/app/layout-portal';
import Link from 'next/link'; import BatchBar from '@/components/BatchBar';
import { Trash2 } from 'lucide-react';
import { recordKey, parseRecordKey } from './helpers';
import { import {
Clock, HardDrive, Copy, ExternalLink, Trash2, Search, Upload, HistoryHeader,
Database, Calendar, Filter, X, CheckCircle, Download, HistorySearch,
Link as LinkIcon, HistorySkeleton,
} from 'lucide-react'; HistoryEmpty,
HistoryTable,
function getMethodBadge(method: UploadRecord['method']): { bg: string; text: string; label: string } { HistoryMobileCard,
switch (method) { HistoryNoResults,
case 'free': return { bg: 'bg-accent-green/10', text: 'text-accent-green', label: 'free' }; HistoryResultsCount,
case 'eth': return { bg: 'bg-brand-500/10', text: 'text-brand-400', label: 'eth' }; } from './components';
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 ════════════════════════════ */ /* ════════════════════════════ Page ════════════════════════════ */
@@ -34,8 +28,6 @@ export default function HistoryPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const recordKey = (r: UploadRecord) => `${r.cid}||${r.date}`;
function load() { function load() {
setHistory(getHistory()); setHistory(getHistory());
setLoading(false); setLoading(false);
@@ -73,7 +65,7 @@ export default function HistoryPage() {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopied(key); setCopied(key);
setTimeout(() => setCopied(null), 2000); setTimeout(() => setCopied(null), 2000);
} catch { /* ignore */ } } catch (err) { console.error('[HistoryPage] Clipboard write failed:', err); }
} }
/* ── Selection ── */ /* ── Selection ── */
@@ -100,12 +92,7 @@ export default function HistoryPage() {
function handleBatchDelete() { function handleBatchDelete() {
const count = selected.size; const count = selected.size;
if (!confirm(`Delete ${count} upload${count !== 1 ? 's' : ''} from history?`)) return; if (!confirm(`Delete ${count} upload${count !== 1 ? 's' : ''} from history?`)) return;
const keys = Array.from(selected).map(parseRecordKey);
const keys = Array.from(selected).map((key) => {
const idx = key.lastIndexOf('||');
return { cid: key.slice(0, idx), date: key.slice(idx + 2) };
});
removeMultipleFromHistory(keys); removeMultipleFromHistory(keys);
setHistory((prev) => prev.filter((r) => !selected.has(recordKey(r)))); setHistory((prev) => prev.filter((r) => !selected.has(recordKey(r))));
setSelected(new Set()); setSelected(new Set());
@@ -117,353 +104,60 @@ export default function HistoryPage() {
<PortalLayout> <PortalLayout>
<ErrorBoundary label="HistoryPage"> <ErrorBoundary label="HistoryPage">
<div className="animate-fade-in"> <div className="animate-fade-in">
{/* ── Header ── */} <HistoryHeader
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6"> totalUploads={stats.totalUploads}
<div> totalSize={stats.totalSize}
<h1 className="text-2xl font-bold text-white">Upload History</h1> uniqueCids={stats.uniqueCids}
<p className="text-sm text-surface-400 mt-1"> onClear={handleClear}
{stats.totalUploads > 0
? `${stats.totalUploads} upload${stats.totalUploads !== 1 ? 's' : ''} · ${formatBytes(stats.totalSize)} total · ${stats.uniqueCids} unique file${stats.uniqueCids !== 1 ? 's' : ''}`
: 'Track your past uploads'}
</p>
</div>
{history.length > 0 && (
<button
onClick={handleClear}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent-rose/10 text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors self-start"
>
<Trash2 className="w-4 h-4" />
Clear History
</button>
)}
</div>
{/* ── Search ── */}
{history.length > 0 && (
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by file name or CID…"
className="w-full pl-10 pr-10 py-2 rounded-lg bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500"
/> />
{search && (
<button {history.length > 0 && (
onClick={() => setSearch('')} <HistorySearch search={search} onChange={setSearch} />
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-700 transition-colors"
>
<X className="w-4 h-4 text-surface-500" />
</button>
)}
</div>
)} )}
{/* ── Loading skeleton ── */} {loading && <HistorySkeleton />}
{loading && (
<div className="glass rounded-xl overflow-hidden">
<div className="px-1">
<SkeletonTable rows={8} cols={3} />
</div>
</div>
)}
{/* ── Empty state ── */} {!loading && history.length === 0 && <HistoryEmpty />}
{!loading && history.length === 0 && (
<div className="glass rounded-xl p-12 text-center animate-fade-in">
<div className="flex justify-center mb-4">
<div className="p-3 rounded-xl bg-surface-800/50">
<Clock className="w-8 h-8 text-surface-500" />
</div>
</div>
<h3 className="text-lg font-semibold text-surface-300 mb-2">No upload history yet</h3>
<p className="text-sm text-surface-500 mb-6 max-w-md mx-auto">
Your uploaded files will appear here so you can easily copy CIDs, download files, or check upload details.
</p>
<Link
href="/upload"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand-600 text-white text-sm font-medium hover:bg-brand-500 transition-colors"
>
<Upload className="w-4 h-4" />
Upload your first file
</Link>
</div>
)}
{/* ── Desktop table ── */}
{filtered.length > 0 && ( {filtered.length > 0 && (
<> <>
{/* Desktop table — hidden on small screens */} <HistoryTable
<div className="hidden sm:block glass rounded-xl overflow-hidden"> records={filtered}
<table className="w-full"> selected={selected}
<thead> copied={copied}
<tr className="border-b border-surface-800 text-left text-xs font-medium text-surface-500 uppercase tracking-wider"> onToggle={toggleSelection}
<th className="px-5 py-3 w-10"> onToggleAll={toggleAll}
<input allSelected={selected.size === filtered.length}
type="checkbox" onCopy={copyToClipboard}
checked={selected.size === filtered.length && filtered.length > 0}
onChange={toggleAll}
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500"
/> />
</th>
<th className="px-5 py-3">File Name</th>
<th className="px-5 py-3">CID</th>
<th className="px-5 py-3">Size</th>
<th className="px-5 py-3">Date</th>
<th className="px-5 py-3">Method</th>
<th className="px-5 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-800">
{filtered.map((rec) => {
const badge = getMethodBadge(rec.method);
const gwLink = gatewayLink(rec.cid);
return (
<tr key={rec.cid + rec.date} className="hover:bg-surface-800/30 transition-colors">
{/* Checkbox */}
<td className="px-5 py-3">
<input
type="checkbox"
checked={selected.has(recordKey(rec))}
onChange={() => toggleSelection(recordKey(rec))}
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500"
/>
</td>
{/* File name */}
<td className="px-5 py-3">
<div className="flex items-center gap-2">
<HardDrive className="w-3.5 h-3.5 text-surface-500 shrink-0" />
<span className="text-sm text-surface-200 truncate max-w-[200px]" title={rec.name}>
{rec.name}
</span>
</div>
</td>
{/* CID */}
<td className="px-5 py-3">
<span
className="text-xs font-mono text-surface-400 cursor-default"
title={rec.cid}
>
{truncateCid(rec.cid)}
</span>
</td>
{/* Size */}
<td className="px-5 py-3">
<span className="text-xs text-surface-400">{formatBytes(rec.size)}</span>
</td>
{/* Date */}
<td className="px-5 py-3">
<div className="text-xs text-surface-400">
<div>{new Date(rec.date).toLocaleDateString()}</div>
<div className="text-surface-500">{new Date(rec.date).toLocaleTimeString()}</div>
</div>
</td>
{/* Method badge */}
<td className="px-5 py-3">
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
</td>
{/* Actions */}
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-1">
{/* Copy CID */}
<button
onClick={() => copyToClipboard(rec.cid, `cid-${rec.cid}`)}
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
title="Copy CID"
>
{copied === `cid-${rec.cid}` ? (
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
) : (
<Copy className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
)}
</button>
{/* Copy gateway link */}
<button
onClick={() => copyToClipboard(gwLink, `gw-${rec.cid}`)}
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
title="Copy gateway link"
>
{copied === `gw-${rec.cid}` ? (
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
) : (
<LinkIcon className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
)}
</button>
{/* Download */}
<button
onClick={() => downloadFile(rec.cid, rec.name).catch(() => {})}
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
title="Download file"
>
<Download className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
</button>
{/* Open in gateway */}
<a
href={gwLink}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors group"
title="Open in gateway"
>
<ExternalLink className="w-3.5 h-3.5 text-surface-500 group-hover:text-surface-300" />
</a>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* ── Mobile cards ── */}
<div className="sm:hidden space-y-3"> <div className="sm:hidden space-y-3">
{filtered.map((rec) => { {filtered.map((rec) => (
const badge = getMethodBadge(rec.method); <HistoryMobileCard
const gwLink = gatewayLink(rec.cid); key={rec.cid + rec.date}
return ( rec={rec}
<div key={rec.cid + rec.date} className="glass rounded-xl p-4 space-y-3"> selected={selected}
{/* Checkbox + Name + method badge */} copied={copied}
<div className="flex items-start justify-between gap-2"> onToggle={toggleSelection}
<div className="flex items-center gap-2 flex-1 min-w-0"> onCopy={copyToClipboard}
<input
type="checkbox"
checked={selected.has(recordKey(rec))}
onChange={() => toggleSelection(recordKey(rec))}
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500 shrink-0 mt-0.5"
/> />
<HardDrive className="w-4 h-4 text-surface-500 shrink-0" /> ))}
<span className="text-sm text-surface-200 truncate" title={rec.name}>
{rec.name}
</span>
</div>
<span className={`shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
</div> </div>
{/* CID */}
<div className="flex items-center gap-2">
<span
className="text-xs font-mono text-surface-400 truncate"
title={rec.cid}
>
{truncateCid(rec.cid)}
</span>
<button
onClick={() => copyToClipboard(rec.cid, `cid-${rec.cid}`)}
className="p-1 rounded-lg hover:bg-surface-700 transition-colors shrink-0"
>
{copied === `cid-${rec.cid}` ? (
<CheckCircle className="w-3 h-3 text-accent-green" />
) : (
<Copy className="w-3 h-3 text-surface-500" />
)}
</button>
</div>
{/* Size + date */}
<div className="flex items-center gap-4 text-xs text-surface-500">
<span>{formatBytes(rec.size)}</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(rec.date).toLocaleDateString()}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(rec.date).toLocaleTimeString()}
</span>
</div>
{/* Actions row */}
<div className="flex items-center gap-2 pt-1">
{/* Copy CID */}
<button
onClick={() => copyToClipboard(rec.cid, `cid-m-${rec.cid}`)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
<Copy className="w-3 h-3" />
Copy CID
</button>
{/* Copy link */}
<button
onClick={() => copyToClipboard(gwLink, `gw-m-${rec.cid}`)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
<LinkIcon className="w-3 h-3" />
Copy Link
</button>
{/* Download */}
<button
onClick={() => downloadFile(rec.cid, rec.name).catch(() => {})}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
<Download className="w-3 h-3" />
Download
</button>
{/* Open */}
<a
href={gwLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
<ExternalLink className="w-3 h-3" />
Open
</a>
</div>
</div>
);
})}
</div>
{/* ── Results count ── */}
{search && filtered.length < history.length && ( {search && filtered.length < history.length && (
<div className="mt-3 text-center text-xs text-surface-500"> <HistoryResultsCount
Showing {filtered.length} of {history.length} uploads filtered={filtered.length}
<button total={history.length}
onClick={() => setSearch('')} onClear={() => setSearch('')}
className="ml-2 text-brand-400 hover:text-brand-300 underline transition-colors" />
>
Clear filter
</button>
</div>
)} )}
</> </>
)} )}
{/* ── No search results ── */}
{!loading && history.length > 0 && filtered.length === 0 && ( {!loading && history.length > 0 && filtered.length === 0 && (
<div className="glass rounded-xl p-12 text-center"> <HistoryNoResults search={search} onClear={() => setSearch('')} />
<div className="flex justify-center mb-3">
<Search className="w-8 h-8 text-surface-500" />
</div>
<h3 className="text-base font-semibold text-surface-300 mb-1">No results found</h3>
<p className="text-sm text-surface-500">
No uploads match &ldquo;{search}&rdquo;
</p>
<button
onClick={() => setSearch('')}
className="mt-4 text-sm text-brand-400 hover:text-brand-300 underline transition-colors"
>
Clear search
</button>
</div>
)} )}
{/* ── Batch bar ── */}
<BatchBar <BatchBar
count={selected.size} count={selected.size}
onClear={() => setSelected(new Set())} onClear={() => setSelected(new Set())}
@@ -472,7 +166,7 @@ export default function HistoryPage() {
key: 'delete', key: 'delete',
label: 'Delete selected', label: 'Delete selected',
icon: <Trash2 className="w-3.5 h-3.5" />, icon: <Trash2 className="w-3.5 h-3.5" />,
variant: 'danger', variant: 'danger' as const,
onClick: handleBatchDelete, onClick: handleBatchDelete,
}, },
]} ]}
+3
View File
@@ -0,0 +1,3 @@
/* ── IPNS Page Helpers ── */
export type Status = "idle" | "loading" | "success" | "error";
+8 -5
View File
@@ -1,11 +1,13 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { listIPNSKeys, ipnsPublish, ipnsResolve, ipnsGenKey, type IPNSKey } from '@/lib/api'; import { listIPNSKeys, ipnsPublish, ipnsResolve, ipnsGenKey, type IPNSKey } from '@/lib/api/ipns';
import PortalLayout from '@/app/layout-portal'; import PortalLayout from '@/app/layout-portal';
import { RefreshCw, ExternalLink, Key, FileText, Plus } from 'lucide-react'; import { RefreshCw, ExternalLink, Key, FileText, Plus } from 'lucide-react';
import { SkeletonTable } from '@/components/Skeleton';
type Status = 'idle' | 'loading' | 'success' | 'error'; import ErrorBoundary from '@/components/ErrorBoundary';
import type { Status } from './helpers';
export default function IPNSPage() { export default function IPNSPage() {
const [keys, setKeys] = useState<IPNSKey[]>([]); const [keys, setKeys] = useState<IPNSKey[]>([]);
@@ -95,6 +97,7 @@ export default function IPNSPage() {
return ( return (
<PortalLayout> <PortalLayout>
<ErrorBoundary label="IPNS">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
@@ -123,9 +126,8 @@ export default function IPNSPage() {
</div> </div>
{status === 'loading' && ( {status === 'loading' && (
<div className="flex items-center gap-2 text-sm text-surface-400 py-4"> <div className="overflow-hidden">
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> <SkeletonTable rows={4} cols={3} />
Loading keys...
</div> </div>
)} )}
@@ -281,6 +283,7 @@ export default function IPNSPage() {
)} )}
</section> </section>
</div> </div>
</ErrorBoundary>
</PortalLayout> </PortalLayout>
); );
} }
+11 -3
View File
@@ -3,12 +3,20 @@
import PortalSidebar from '@/components/PortalSidebar'; import PortalSidebar from '@/components/PortalSidebar';
import PortalHeader from '@/components/PortalHeader'; import PortalHeader from '@/components/PortalHeader';
export default function PortalLayout({ children }: { children: React.ReactNode }) { interface PortalLayoutProps {
children: React.ReactNode;
/** Optional custom header. Defaults to <PortalHeader /> if omitted. */
header?: React.ReactNode;
/** Optional custom sidebar. Defaults to <PortalSidebar /> if omitted. */
sidebar?: React.ReactNode;
}
export default function PortalLayout({ children, header, sidebar }: PortalLayoutProps) {
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen">
<PortalSidebar /> {sidebar ?? <PortalSidebar />}
<div className="flex-1 flex flex-col ml-56"> <div className="flex-1 flex flex-col ml-56">
<PortalHeader /> {header ?? <PortalHeader />}
<main className="flex-1 p-6">{children}</main> <main className="flex-1 p-6">{children}</main>
</div> </div>
</div> </div>
+2 -2
View File
@@ -29,8 +29,8 @@ export default function LoginPage() {
await loginWithWallet(address, dw.provider); await loginWithWallet(address, dw.provider);
notify({ type: 'success', title: 'Ingelogd', message: `Wallet ${address.slice(0, 6)}${address.slice(-4)}` }); notify({ type: 'success', title: 'Ingelogd', message: `Wallet ${address.slice(0, 6)}${address.slice(-4)}` });
router.push('/dashboard'); router.push('/dashboard');
} catch (err: any) { } catch (err: unknown) {
notify({ type: 'error', title: 'Login mislukt', message: err.message || 'Onbekende fout' }); notify({ type: 'error', title: 'Login mislukt', message: err instanceof Error ? err.message : 'Onbekende fout' });
} finally { } finally {
setBusy(false); setBusy(false);
setBusyWallet(null); setBusyWallet(null);
+3 -3
View File
@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { connectWallet, signMessage } from '@/lib/wallet'; import { connectWallet, signMessage } from '@/lib/wallet';
import { checkHealth } from '@/lib/api'; import { checkHealth } from '@/lib/api/gateway';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { HardDrive, Globe, Lock, ArrowRight, Server, Zap } from 'lucide-react'; import { HardDrive, Globe, Lock, ArrowRight, Server, Zap } from 'lucide-react';
@@ -21,8 +21,8 @@ export default function LandingPage() {
// Non-blocking health check — portal UI werkt ook zonder backend // Non-blocking health check — portal UI werkt ook zonder backend
checkHealth().catch(() => {}); checkHealth().catch(() => {});
router.push('/dashboard'); router.push('/dashboard');
} catch (e: any) { } catch (e: unknown) {
setError(e.message || 'Connection failed'); setError(e instanceof Error ? e.message : 'Connection failed');
} finally { } finally {
setLoading(false); setLoading(false);
} }
+6 -2
View File
@@ -1,11 +1,13 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getPeers } from '@/lib/api'; import { getPeers } from '@/lib/api/gateway';
import PortalLayout from '@/app/layout-portal'; import PortalLayout from '@/app/layout-portal';
import { SkeletonTable } from '@/components/Skeleton'; import { SkeletonTable } from '@/components/Skeleton';
import { Wifi, Globe, Clock } from 'lucide-react'; import { Wifi, Globe, Clock } from 'lucide-react';
import ErrorBoundary from '@/components/ErrorBoundary';
export default function PeersPage() { export default function PeersPage() {
const [peers, setPeers] = useState<{ id: string; addr: string; latency: string }[]>([]); const [peers, setPeers] = useState<{ id: string; addr: string; latency: string }[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -15,7 +17,7 @@ export default function PeersPage() {
try { try {
const items = await getPeers(); const items = await getPeers();
setPeers(items); setPeers(items);
} catch { /* ignore */ } } catch (err) { console.error('[PeersPage] Failed to load peers:', err); }
finally { setLoading(false); } finally { setLoading(false); }
} }
load(); load();
@@ -25,6 +27,7 @@ export default function PeersPage() {
return ( return (
<PortalLayout> <PortalLayout>
<ErrorBoundary label="Peers">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-white">Peers</h1> <h1 className="text-2xl font-bold text-white">Peers</h1>
<p className="text-sm text-surface-400 mt-1">{peers.length} connected peers</p> <p className="text-sm text-surface-400 mt-1">{peers.length} connected peers</p>
@@ -59,6 +62,7 @@ export default function PeersPage() {
</div> </div>
)} )}
</div> </div>
</ErrorBoundary>
</PortalLayout> </PortalLayout>
); );
} }
+10 -6
View File
@@ -1,13 +1,14 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { listPins, addPin, removePin } from '@/lib/api'; import { listPins, addPin, removePin } from '@/lib/api/pins';
import PortalLayout from '@/app/layout-portal'; import PortalLayout from '@/app/layout-portal';
import Link from 'next/link'; import Link from 'next/link';
import { HardDrive, Trash2, Plus, Search, Copy, CheckCircle, ExternalLink, Download } from 'lucide-react'; import { HardDrive, Trash2, Plus, Search, Copy, CheckCircle, ExternalLink, Download } from 'lucide-react';
import Skeleton, { SkeletonRow, SkeletonTable } from '@/components/Skeleton'; import Skeleton, { SkeletonRow, SkeletonTable } from '@/components/Skeleton';
import BatchBar, { type BatchAction } from '@/components/BatchBar'; import BatchBar, { type BatchAction } from '@/components/BatchBar';
import { useNotify } from '@/lib/notifications'; import { useNotify } from '@/lib/notifications';
import ErrorBoundary from '@/components/ErrorBoundary';
export default function PinsPage() { export default function PinsPage() {
const [pins, setPins] = useState<{ cid: string; name: string; size: number; created: string }[]>([]); const [pins, setPins] = useState<{ cid: string; name: string; size: number; created: string }[]>([]);
@@ -25,7 +26,7 @@ export default function PinsPage() {
try { try {
const items = await listPins(); const items = await listPins();
setPins(items); setPins(items);
} catch { /* ignore */ } } catch (err) { console.error('[PinsPage] Failed to load pins:', err); }
finally { setLoading(false); } finally { setLoading(false); }
} }
@@ -39,14 +40,14 @@ export default function PinsPage() {
setNewName(''); setNewName('');
setShowAdd(false); setShowAdd(false);
await load(); await load();
} catch { /* ignore */ } } catch (err) { console.error('[PinsPage] Failed to add pin:', err); }
} }
async function handleRemove(cid: string) { async function handleRemove(cid: string) {
try { try {
await removePin(cid); await removePin(cid);
setPins((p) => p.filter((x) => x.cid !== cid)); setPins((p) => p.filter((x) => x.cid !== cid));
} catch { /* ignore */ } } catch (err) { console.error('[PinsPage] Failed to remove pin:', err); }
} }
async function copyCid(cid: string) { async function copyCid(cid: string) {
@@ -54,7 +55,7 @@ export default function PinsPage() {
await navigator.clipboard.writeText(cid); await navigator.clipboard.writeText(cid);
setCopied(cid); setCopied(cid);
setTimeout(() => setCopied(null), 2000); setTimeout(() => setCopied(null), 2000);
} catch { /* ignore */ } } catch (err) { console.error('[PinsPage] Clipboard write failed:', err); }
} }
function toggleSelect(cid: string) { function toggleSelect(cid: string) {
@@ -78,7 +79,8 @@ export default function PinsPage() {
try { try {
await removePin(cid); await removePin(cid);
success++; success++;
} catch { } catch (err) {
console.error('[PinsPage] Batch unpin failed for CID:', err);
fail++; fail++;
} }
} }
@@ -98,6 +100,7 @@ export default function PinsPage() {
return ( return (
<PortalLayout> <PortalLayout>
<ErrorBoundary label="Pinnen">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-2xl font-bold text-white">Pins</h1> <h1 className="text-2xl font-bold text-white">Pins</h1>
@@ -225,6 +228,7 @@ export default function PinsPage() {
onClear={clearSelection} onClear={clearSelection}
label="selected" label="selected"
/> />
</ErrorBoundary>
</PortalLayout> </PortalLayout>
); );
} }
+2 -2
View File
@@ -53,8 +53,8 @@ export default function ProfilePage() {
setCurrentPass(''); setCurrentPass('');
setNewPass(''); setNewPass('');
setConfirmPass(''); setConfirmPass('');
} catch (err: any) { } catch (err: unknown) {
setError(err.message); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
setBusy(false); setBusy(false);
} }
+2 -2
View File
@@ -52,8 +52,8 @@ export default function RegisterPage() {
message: `Welkom, ${username.trim()}! Je kunt nu inloggen.`, message: `Welkom, ${username.trim()}! Je kunt nu inloggen.`,
}); });
router.push('/login'); router.push('/login');
} catch (err: any) { } catch (err: unknown) {
setError(err.message || 'Registratie mislukt'); setError(err instanceof Error ? err.message : 'Registratie mislukt');
} finally { } finally {
setBusy(false); setBusy(false);
} }
+60
View File
@@ -0,0 +1,60 @@
"use client";
import type { ReactNode } from "react";
import { Save, RotateCcw, Check, AlertTriangle } from "lucide-react";
interface SettingsActionsProps {
dirty: boolean;
saved: boolean;
error: string | null;
onSave: () => void;
onReset: () => void;
}
export function errorMsg(field: string, validation: Record<string, string | null>): ReactNode {
return validation[field] ? (
<p className="mt-1 text-xs text-accent-rose">{validation[field]}</p>
) : null;
}
export function SettingsActions({ dirty, saved, error, onSave, onReset }: SettingsActionsProps) {
return (
<div className="glass rounded-xl p-5 animate-fade-in">
<div className="flex flex-wrap items-center gap-3">
<button
onClick={onSave}
disabled={!dirty && !error}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
>
{saved ? (
<><Check className="w-4 h-4 text-accent-green" /> Saved!</>
) : (
<><Save className="w-4 h-4" /> Save Settings</>
)}
</button>
<button
onClick={onReset}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-800 hover:bg-surface-700 text-surface-300 text-sm font-medium transition-colors"
>
<RotateCcw className="w-4 h-4" />
Reset Defaults
</button>
{dirty && !saved && (
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-amber/10 text-accent-amber text-xs">
<AlertTriangle className="w-3 h-3" />
Unsaved changes
</span>
)}
{error && (
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-rose/10 text-accent-rose text-xs">
<AlertTriangle className="w-3 h-3" />
{error}
</span>
)}
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
/* ── Settings Page Helpers ── */
import type { PortalSettings } from "@/lib/storage";
export function formatStorageMB(mb: number): string {
if (mb >= 1024) return (mb / 1024).toFixed(0) + " GB";
return mb + " MB";
}
export function inputCls(field: string, validation: Record<string, string | null>): 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`;
}
export function validateSettings(
form: PortalSettings,
): { valid: boolean; errors: Record<string, string | null> } {
const errors: Record<string, string | null> = {};
let valid = true;
if (!form.gatewayUrl.startsWith("http://") && !form.gatewayUrl.startsWith("https://")) {
errors.gatewayUrl = "Must start with http:// or https://";
valid = false;
}
if (form.gatewayUrl.length > 500) {
errors.gatewayUrl = "Too long (max 500 chars)";
valid = false;
}
if (form.apiEndpoint && !form.apiEndpoint.startsWith("/") && !form.apiEndpoint.startsWith("http")) {
errors.apiEndpoint = "Must be empty, a path (/…), or a URL";
valid = false;
}
if (form.storageMax < 1 || form.storageMax > 102400) {
errors.storageMax = "Must be 1102400 MB";
valid = false;
}
if (form.refreshInterval < 5 || form.refreshInterval > 300) {
errors.refreshInterval = "Must be 5300 seconds";
valid = false;
}
return { valid, errors };
}
+31 -119
View File
@@ -3,25 +3,16 @@
import { useState } from 'react'; import { useState } from 'react';
import { getSettings, saveSettings, resetSettings, type PortalSettings } from '@/lib/storage'; import { getSettings, saveSettings, resetSettings, type PortalSettings } from '@/lib/storage';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
import { formatBytes } from '@/lib/helpers';
import PortalLayout from '@/app/layout-portal'; import PortalLayout from '@/app/layout-portal';
import { import {
Settings, Globe, Server, HardDrive, RefreshCw, Shield, Globe, Server, HardDrive, RefreshCw, Shield,
Sun, Moon, Save, RotateCcw, Check, AlertTriangle, Sun, Moon,
} from 'lucide-react'; } from 'lucide-react';
import ErrorBoundary from '@/components/ErrorBoundary';
/* ── Helpers ── */ import { formatStorageMB, inputCls, validateSettings } from './helpers';
import { errorMsg } from './components';
function formatStorageMB(mb: number): string { import { SettingsActions } from './components';
if (mb >= 1024) return (mb / 1024).toFixed(0) + ' GB';
return mb + ' MB';
}
function formatBytes(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));
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}
/* ════════════════════════════ Page ════════════════════════════ */ /* ════════════════════════════ Page ════════════════════════════ */
@@ -40,47 +31,19 @@ export default function SettingsPage() {
setForm(prev => ({ ...prev, [key]: value })); setForm(prev => ({ ...prev, [key]: value }));
setSaved(false); setSaved(false);
setError(null); setError(null);
// Clear validation error on change
if (validation[key]) { if (validation[key]) {
setValidation(prev => ({ ...prev, [key]: null })); setValidation(prev => ({ ...prev, [key]: null }));
} }
} }
/* ── Validate ── */
function validate(): boolean {
const errs: Record<string, string | null> = {};
let valid = true;
if (!form.gatewayUrl.startsWith('http://') && !form.gatewayUrl.startsWith('https://')) {
errs.gatewayUrl = 'Must start with http:// or https://';
valid = false;
}
if (form.gatewayUrl.length > 500) {
errs.gatewayUrl = 'Too long (max 500 chars)';
valid = false;
}
if (form.apiEndpoint && !form.apiEndpoint.startsWith('/') && !form.apiEndpoint.startsWith('http')) {
errs.apiEndpoint = 'Must be empty, a path (/…), or a URL';
valid = false;
}
if (form.storageMax < 1 || form.storageMax > 102400) {
errs.storageMax = 'Must be 1102400 MB';
valid = false;
}
if (form.refreshInterval < 5 || form.refreshInterval > 300) {
errs.refreshInterval = 'Must be 5300 seconds';
valid = false;
}
setValidation(errs);
if (!valid) setError('Fix validation errors before saving');
return valid;
}
/* ── Save ── */ /* ── Save ── */
function handleSave() { function handleSave() {
if (!validate()) return; const result = validateSettings(form);
setValidation(result.errors);
if (!result.valid) {
setError('Fix validation errors before saving');
return;
}
const merged = saveSettings(form); const merged = saveSettings(form);
setForm(merged); setForm(merged);
setOriginal({ ...merged }); setOriginal({ ...merged });
@@ -100,18 +63,6 @@ export default function SettingsPage() {
setValidation({}); setValidation({});
} }
/* ── Render helpers ── */
function inputCls(field: string): string {
return `w-full rounded-lg bg-surface-900 border ${validation[field] ? 'border-accent-rose/50' : 'border-surface-700'} px-3 py-2 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-colors`;
}
function errorMsg(field: string) {
return validation[field] ? (
<p className="mt-1 text-xs text-accent-rose">{validation[field]}</p>
) : null;
}
/* ════════════ Sections ════════════ */ /* ════════════ Sections ════════════ */
const sections = [ const sections = [
@@ -126,9 +77,9 @@ export default function SettingsPage() {
value={form.gatewayUrl} value={form.gatewayUrl}
onChange={e => update('gatewayUrl', e.target.value)} onChange={e => update('gatewayUrl', e.target.value)}
placeholder="https://ipfs.io/ipfs" placeholder="https://ipfs.io/ipfs"
className={inputCls('gatewayUrl')} className={inputCls('gatewayUrl', validation)}
/> />
{errorMsg('gatewayUrl')} {errorMsg('gatewayUrl', validation)}
<p className="mt-1.5 text-[11px] text-surface-500"> <p className="mt-1.5 text-[11px] text-surface-500">
Base URL for IPFS gateway links. Used in dashboard, history, and share links. Base URL for IPFS gateway links. Used in dashboard, history, and share links.
</p> </p>
@@ -146,9 +97,9 @@ export default function SettingsPage() {
value={form.apiEndpoint} value={form.apiEndpoint}
onChange={e => update('apiEndpoint', e.target.value)} onChange={e => update('apiEndpoint', e.target.value)}
placeholder="(same-origin)" placeholder="(same-origin)"
className={inputCls('apiEndpoint')} className={inputCls('apiEndpoint', validation)}
/> />
{errorMsg('apiEndpoint')} {errorMsg('apiEndpoint', validation)}
<p className="mt-1.5 text-[11px] text-surface-500"> <p className="mt-1.5 text-[11px] text-surface-500">
Leave empty to use the same origin. Set to a custom URL to proxy through another server. Leave empty to use the same origin. Set to a custom URL to proxy through another server.
</p> </p>
@@ -185,7 +136,7 @@ export default function SettingsPage() {
/> />
<span className="text-xs text-surface-500">MB</span> <span className="text-xs text-surface-500">MB</span>
</div> </div>
{errorMsg('storageMax')} {errorMsg('storageMax', validation)}
<p className="mt-1.5 text-[11px] text-surface-500"> <p className="mt-1.5 text-[11px] text-surface-500">
Maximum storage the IPFS node should use. Applied server-side. Maximum storage the IPFS node should use. Applied server-side.
</p> </p>
@@ -198,23 +149,20 @@ export default function SettingsPage() {
iconColor: 'text-accent-rose', iconColor: 'text-accent-rose',
fields: ( fields: (
<div className="space-y-4"> <div className="space-y-4">
{/* Allowed file types */}
<div> <div>
<label className="text-xs text-surface-400 mb-1.5 block">Allowed File Extensions</label> <label className="text-xs text-surface-400 mb-1.5 block">Allowed File Extensions</label>
<input <input
value={form.allowedFileTypes} value={form.allowedFileTypes}
onChange={e => update('allowedFileTypes', e.target.value)} onChange={e => update('allowedFileTypes', e.target.value)}
placeholder=".jpg,.png,.pdf,.mp4 (leave empty for all)" placeholder=".jpg,.png,.pdf,.mp4 (leave empty for all)"
className={inputCls('allowedFileTypes')} className={inputCls('allowedFileTypes', validation)}
/> />
{errorMsg('allowedFileTypes')} {errorMsg('allowedFileTypes', validation)}
<p className="mt-1.5 text-[11px] text-surface-500"> <p className="mt-1.5 text-[11px] text-surface-500">
Comma-separated extensions. Empty = all file types accepted. Comma-separated extensions. Empty = all file types accepted. Extension check is case-insensitive.
Extension check is case-insensitive.
</p> </p>
</div> </div>
{/* Max file size */}
<div> <div>
<label className="text-xs text-surface-400 mb-1.5 block"> <label className="text-xs text-surface-400 mb-1.5 block">
Max File Size: <span className="text-surface-200 font-medium">{formatBytes(form.maxFileSize)}</span> Max File Size: <span className="text-surface-200 font-medium">{formatBytes(form.maxFileSize)}</span>
@@ -244,7 +192,6 @@ export default function SettingsPage() {
</p> </p>
</div> </div>
{/* Max files per batch */}
<div> <div>
<label className="text-xs text-surface-400 mb-1.5 block"> <label className="text-xs text-surface-400 mb-1.5 block">
Max Files Per Upload: <span className="text-surface-200 font-medium">{form.maxFiles}</span> Max Files Per Upload: <span className="text-surface-200 font-medium">{form.maxFiles}</span>
@@ -306,7 +253,7 @@ export default function SettingsPage() {
/> />
<span className="text-xs text-surface-500">sec</span> <span className="text-xs text-surface-500">sec</span>
</div> </div>
{errorMsg('refreshInterval')} {errorMsg('refreshInterval', validation)}
<p className="mt-1.5 text-[11px] text-surface-500"> <p className="mt-1.5 text-[11px] text-surface-500">
How often the dashboard auto-refreshes peer/bandwidth data. How often the dashboard auto-refreshes peer/bandwidth data.
</p> </p>
@@ -346,13 +293,12 @@ export default function SettingsPage() {
return ( return (
<PortalLayout> <PortalLayout>
{/* ── Header ── */} <ErrorBoundary label="Instellingen">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-white">Settings</h1> <h1 className="text-2xl font-bold text-white">Settings</h1>
<p className="text-sm text-surface-400 mt-1">IPFS Portal configuration</p> <p className="text-sm text-surface-400 mt-1">IPFS Portal configuration</p>
</div> </div>
{/* ── Settings grid ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{sections.map((s) => ( {sections.map((s) => (
<div key={s.title} className="glass rounded-xl p-5 animate-fade-in"> <div key={s.title} className="glass rounded-xl p-5 animate-fade-in">
@@ -365,48 +311,14 @@ export default function SettingsPage() {
))} ))}
</div> </div>
{/* ── Actions + status ── */} <SettingsActions
<div className="glass rounded-xl p-5 animate-fade-in"> dirty={dirty}
<div className="flex flex-wrap items-center gap-3"> saved={saved}
{/* Save */} error={error}
<button onSave={handleSave}
onClick={handleSave} onReset={handleReset}
disabled={!dirty && !error} />
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors" </ErrorBoundary>
>
{saved ? (
<><Check className="w-4 h-4 text-accent-green" /> Saved!</>
) : (
<><Save className="w-4 h-4" /> Save Settings</>
)}
</button>
{/* Reset */}
<button
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-800 hover:bg-surface-700 text-surface-300 text-sm font-medium transition-colors"
>
<RotateCcw className="w-4 h-4" />
Reset Defaults
</button>
{/* Dirty indicator */}
{dirty && !saved && (
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-amber/10 text-accent-amber text-xs">
<AlertTriangle className="w-3 h-3" />
Unsaved changes
</span>
)}
{/* Error */}
{error && (
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-rose/10 text-accent-rose text-xs">
<AlertTriangle className="w-3 h-3" />
{error}
</span>
)}
</div>
</div>
</PortalLayout> </PortalLayout>
); );
} }
+2 -10
View File
@@ -4,16 +4,8 @@ import { type RefObject } from 'react';
import { import {
File, CheckCircle, Copy, ExternalLink, Trash2, ArrowUp, File, CheckCircle, Copy, ExternalLink, Trash2, ArrowUp,
} from 'lucide-react'; } from 'lucide-react';
import PaymentPanel from '@/components/PaymentPanel'; import { formatBytes } from '@/lib/helpers';
import PaymentPanel from '@/components/payment';
/* ── 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];
}
/* ── Props ── */ /* ── Props ── */
interface CryptoUploadProps { interface CryptoUploadProps {
+1 -9
View File
@@ -5,17 +5,9 @@ import {
Upload, File, CheckCircle, XCircle, Loader2, Upload, File, CheckCircle, XCircle, Loader2,
Copy, ExternalLink, Trash2, Globe, AlertTriangle, Copy, ExternalLink, Trash2, Globe, AlertTriangle,
} from 'lucide-react'; } from 'lucide-react';
import { formatBytes } from '@/lib/helpers';
import { getSettings } from '@/lib/storage'; import { getSettings } from '@/lib/storage';
/* ── 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];
}
export interface FileEntry { export interface FileEntry {
id: string; id: string;
file: File; file: File;
+3
View File
@@ -0,0 +1,3 @@
/* ── Upload Page Helpers ── */
export type TabMode = "quick" | "crypto";
+10 -11
View File
@@ -1,16 +1,17 @@
'use client'; 'use client';
import { useState, useRef, type DragEvent, useCallback, useMemo } from 'react'; import { useState, useRef, type DragEvent, useCallback, useMemo } from 'react';
import { uploadFile } from '@/lib/api'; import { uploadFile } from '@/lib/api/files';
import { addToHistory, getSettings } from '@/lib/storage'; import { addToHistory, getSettings } from '@/lib/storage';
import PortalLayout from '@/app/layout-portal'; import PortalLayout from '@/app/layout-portal';
import { checkUploadAllowed } from '@/lib/limits'; import { checkUploadAllowed } from '@/lib/limits';
import { useNotify } from '@/lib/notifications'; import { useNotify } from '@/lib/notifications';
import { formatBytes, gatewayLink } from '@/lib/helpers';
import { Zap, ArrowUp } from 'lucide-react'; import { Zap, ArrowUp } from 'lucide-react';
import QuickUpload, { type FileEntry } from './components/QuickUpload'; import QuickUpload, { type FileEntry } from './components/QuickUpload';
import { PaymentProvider } from '@/lib/payment';
import CryptoUpload from './components/CryptoUpload'; import CryptoUpload from './components/CryptoUpload';
import type { TabMode } from './helpers';
type TabMode = 'quick' | 'crypto';
/* ════════════════════════════ Page ════════════════════════════ */ /* ════════════════════════════ Page ════════════════════════════ */
@@ -145,9 +146,9 @@ export default function UploadPage() {
setFiles(prev => prev.map(f => setFiles(prev => prev.map(f =>
f.id === entry.id ? { ...f, status: 'done' as const, result, date } : f f.id === entry.id ? { ...f, status: 'done' as const, result, date } : f
)); ));
} catch (e: any) { } catch (e: unknown) {
setFiles(prev => prev.map(f => setFiles(prev => prev.map(f =>
f.id === entry.id ? { ...f, status: 'error' as const, errorMsg: e.message || 'Upload failed' } : f f.id === entry.id ? { ...f, status: 'error' as const, errorMsg: e instanceof Error ? e.message : 'Upload failed' } : f
)); ));
} }
} }
@@ -184,22 +185,18 @@ export default function UploadPage() {
/* ── Clipboard ── */ /* ── Clipboard ── */
function copyCid(cid: string) { function copyCid(cid: string) {
navigator.clipboard.writeText(cid).catch(() => {}); navigator.clipboard.writeText(cid).catch((err) => { console.error('[UploadPage] Clipboard CID failed:', err); });
setCopied(cid); setCopied(cid);
setTimeout(() => setCopied(null), 2000); setTimeout(() => setCopied(null), 2000);
} }
function copyShareLink(cid: string) { function copyShareLink(cid: string) {
const link = gatewayLink(cid); const link = gatewayLink(cid);
navigator.clipboard.writeText(link).catch(() => {}); navigator.clipboard.writeText(link).catch((err) => { console.error('[UploadPage] Clipboard share link failed:', err); });
setCopied(`gw-${cid}`); setCopied(`gw-${cid}`);
setTimeout(() => setCopied(null), 2000); setTimeout(() => setCopied(null), 2000);
} }
function gatewayLink(cid: string) {
return `https://ipfs.maos.dedyn.io/${cid}`;
}
/* ════════════ Render ════════════ */ /* ════════════ Render ════════════ */
return ( return (
@@ -262,6 +259,7 @@ export default function UploadPage() {
{/* Crypto Payment tab */} {/* Crypto Payment tab */}
{tab === 'crypto' && ( {tab === 'crypto' && (
<PaymentProvider>
<CryptoUpload <CryptoUpload
cryptoFile={cryptoFile} cryptoFile={cryptoFile}
cryptoResult={cryptoResult} cryptoResult={cryptoResult}
@@ -275,6 +273,7 @@ export default function UploadPage() {
onCopyCid={copyCid} onCopyCid={copyCid}
gatewayLink={gatewayLink} gatewayLink={gatewayLink}
/> />
</PaymentProvider>
)} )}
</PortalLayout> </PortalLayout>
); );
+3 -2
View File
@@ -2,8 +2,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import PortalLayout from '@/app/layout-portal'; import PortalLayout from '@/app/layout-portal';
import { getRepoStats, getPeers, listPins } from '@/lib/api'; import { getRepoStats, getPeers } from '@/lib/api/gateway';
import type { RepoStats } from '@/lib/api'; import { listPins } from '@/lib/api/pins';
import type { RepoStats } from '@/lib/api/client';
import { checkUploadAllowed, type UsageCheckResult } from '@/lib/limits'; import { checkUploadAllowed, type UsageCheckResult } from '@/lib/limits';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import StorageGauge from '@/app/dashboard/components/StorageGauge'; import StorageGauge from '@/app/dashboard/components/StorageGauge';
+6
View File
@@ -0,0 +1,6 @@
/* ── Users Page Helpers ── */
export function formatTimestamp(ts: bigint): string {
const d = new Date(Number(ts) * 1000);
return d.toLocaleDateString() + " " + d.toLocaleTimeString();
}
+16 -14
View File
@@ -1,18 +1,23 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { listUsers, createUser, deleteUser } from '@/lib/api'; import { listUsers, createUser, deleteUser } from '@/lib/api/users';
import PortalLayout from '@/app/layout-portal'; import PortalLayout from '@/app/layout-portal';
import { SkeletonTable } from '@/components/Skeleton'; import { SkeletonTable } from '@/components/Skeleton';
import { paymentService, type UserFullStats, type UploadRecord } from '@/lib/payment'; import { usePaymentService, type UserFullStats, type UploadRecord } from '@/lib/payment';
import { formatWeiToETH, formatBytes } from '@/lib/wallet'; import { formatBytes } from '@/lib/helpers';
import { formatWeiToETH } from '@/lib/wallet';
import { formatTimestamp } from './helpers';
import { import {
Users, Plus, Trash2, UserPlus, Search, Wallet, Users, Plus, Trash2, UserPlus, Search, Wallet,
HardDrive, Upload, Clock, ExternalLink, Copy, HardDrive, Upload, Clock, ExternalLink, Copy,
CheckCircle, Loader2, XCircle, CheckCircle, Loader2, XCircle,
} from 'lucide-react'; } from 'lucide-react';
import ErrorBoundary from '@/components/ErrorBoundary';
export default function UsersPage() { export default function UsersPage() {
const paymentService = usePaymentService();
// ── htpasswd users ── // ── htpasswd users ──
const [users, setUsers] = useState<{ username: string; created: string; active: boolean }[]>([]); const [users, setUsers] = useState<{ username: string; created: string; active: boolean }[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -31,7 +36,7 @@ export default function UsersPage() {
try { try {
const items = await listUsers(); const items = await listUsers();
setUsers(items); setUsers(items);
} catch { /* ignore */ } } catch (err) { console.error('[UsersPage] Failed to load users:', err); }
finally { setLoading(false); } finally { setLoading(false); }
} }
@@ -45,14 +50,14 @@ export default function UsersPage() {
setNewPass(''); setNewPass('');
setShowAdd(false); setShowAdd(false);
await load(); await load();
} catch { /* ignore */ } } catch (err) { console.error('[UsersPage] Failed to create user:', err); }
} }
async function handleDelete(username: string) { async function handleDelete(username: string) {
try { try {
await deleteUser(username); await deleteUser(username);
setUsers((u) => u.filter((x) => x.username !== username)); setUsers((u) => u.filter((x) => x.username !== username));
} catch { /* ignore */ } } catch (err) { console.error('[UsersPage] Failed to delete user:', err); }
} }
// ── Wallet lookup ── // ── Wallet lookup ──
@@ -68,8 +73,8 @@ export default function UsersPage() {
try { try {
const stats = await paymentService.getUserUploads(addr as `0x${string}`); const stats = await paymentService.getUserUploads(addr as `0x${string}`);
setWalletStats(stats); setWalletStats(stats);
} catch (e: any) { } catch (e: unknown) {
setWalletError(e.message || 'Failed to fetch wallet stats'); setWalletError(e instanceof Error ? e.message : 'Failed to fetch wallet stats');
} finally { } finally {
setWalletLoading(false); setWalletLoading(false);
} }
@@ -80,16 +85,12 @@ export default function UsersPage() {
await navigator.clipboard.writeText(cid); await navigator.clipboard.writeText(cid);
setCopiedCid(cid); setCopiedCid(cid);
setTimeout(() => setCopiedCid(null), 2000); setTimeout(() => setCopiedCid(null), 2000);
} catch { /* ignore */ } } catch (err) { console.error('[UsersPage] Clipboard write failed:', err); }
}
function formatTimestamp(ts: bigint): string {
const d = new Date(Number(ts) * 1000);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
} }
return ( return (
<PortalLayout> <PortalLayout>
<ErrorBoundary label="Gebruikers">
{/* ── Header with count badge ── */} {/* ── Header with count badge ── */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
@@ -271,6 +272,7 @@ export default function UsersPage() {
)} )}
</div> </div>
</div> </div>
</ErrorBoundary>
</PortalLayout> </PortalLayout>
); );
} }
-390
View File
@@ -1,390 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { paymentService, type PriceInfo, type TokenInfo, type MAOSDiscountTier, type UserUploadStats } from '@/lib/payment';
import {
connectWallet, switchToChain270, getInjectedProvider,
formatWeiToETH, formatBytes, type WalletProvider,
} from '@/lib/wallet';
import {
Wallet, Coins, Loader2, CheckCircle, XCircle,
Zap, Shield, Tag, ArrowRight, ExternalLink, Copy,
} from 'lucide-react';
/* ── Token icons ── */
const TOKEN_ICONS: Record<string, string> = {
ETH: '⟠',
MAOS: 'Ⓜ',
USDC: '⬡',
};
interface PaymentPanelProps {
fileSize: number; // bytes
fileName?: string;
onPaid: (paymentInfo: { cid: string; txHash?: string; method: 'free' | 'eth' | 'token' }) => void;
onUpload: () => Promise<{ cid: string; size: number }>;
contractAddress?: string;
}
export default function PaymentPanel({
fileSize,
fileName,
onPaid,
onUpload,
contractAddress,
}: PaymentPanelProps) {
// Wallet state
const [address, setAddress] = useState<string | null>(null);
const [provider, setProvider] = useState<WalletProvider | null>(null);
const [connecting, setConnecting] = useState(false);
const [walletType, setWalletType] = useState<string | null>(null);
const [wrongChain, setWrongChain] = useState(false);
// Contract state
const [deployed, setDeployed] = useState(false);
const [price, setPrice] = useState<PriceInfo | null>(null);
const [userStats, setUserStats] = useState<UserUploadStats | null>(null);
const [tokens, setTokens] = useState<TokenInfo[]>([]);
const [discountTiers, setDiscountTiers] = useState<MAOSDiscountTier[]>([]);
const [selectedToken, setSelectedToken] = useState('ETH');
const [freeTierBytes, setFreeTierBytes] = useState<bigint>(BigInt(0));
// Upload state
const [uploading, setUploading] = useState(false);
const [paying, setPaying] = useState(false);
const [status, setStatus] = useState<'idle' | 'paying' | 'uploading' | 'complete' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const [txHash, setTxHash] = useState<string | null>(null);
const [uploadResult, setUploadResult] = useState<{ cid: string; size: number } | null>(null);
const [copied, setCopied] = useState(false);
// Init
useEffect(() => {
if (contractAddress) {
paymentService.setContractAddress(contractAddress);
}
paymentService.isDeployed().then(setDeployed).catch(() => setDeployed(false));
}, [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
if (window.maosv6) setWalletType('MAOS Wallet');
else if (window.ethereum?.isRabby) setWalletType('Rabby');
else if (window.ethereum?.isMetaMask) setWalletType('MetaMask');
else setWalletType('Wallet');
// Check chain
if (result.chainId !== 270) {
setWrongChain(true);
const switched = await switchToChain270(prov || undefined);
if (switched) setWrongChain(false);
}
await refreshPricing(result.address, fileSize);
} catch (e: any) {
setError(e.message || 'Failed to connect wallet');
} finally {
setConnecting(false);
}
}
// Refresh when fileSize changes
useEffect(() => {
if (address && deployed) {
refreshPricing(address, fileSize);
}
}, [fileSize, address, deployed, refreshPricing]);
// Pay + Upload flow
async function handlePayAndUpload() {
if (!provider || !address) return;
setPaying(true);
setError(null);
setStatus('paying');
try {
// Step 1: Upload to IPFS
setStatus('uploading');
const upload = await onUpload();
setUploadResult(upload);
// Step 2: Pay (if not free)
if (price && !price.isFree && price.finalWei > BigInt(0)) {
if (selectedToken === 'ETH') {
const hash = await paymentService.payWithETH(provider, upload.cid, fileSize, price.finalWei);
setTxHash(hash);
onPaid({ cid: upload.cid, txHash: hash, method: 'eth' });
} else {
// Token payment — need to find token address
const token = tokens.find(t => t.symbol === selectedToken);
if (!token || token.address === '0x0000000000000000000000000000000000000000') {
throw new Error(`${selectedToken} not configured on contract`);
}
// Calculate token amount
const tokenAmount = (price.finalWei * BigInt(10 ** token.decimals)) / token.weiPerToken;
const result = await paymentService.approveAndPayWithToken(
provider, token.address as `0x${string}`,
upload.cid, fileSize, tokenAmount, selectedToken
);
setTxHash(result.payHash);
onPaid({ cid: upload.cid, txHash: result.payHash, method: 'token' });
}
} else {
// Free upload
onPaid({ cid: upload.cid, method: 'free' });
}
setStatus('complete');
} catch (e: any) {
setError(e.message || 'Payment/upload failed');
setStatus('error');
} finally {
setPaying(false);
}
}
async function copyCID() {
if (!uploadResult) return;
try {
await navigator.clipboard.writeText(uploadResult.cid);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch { /* ignore */ }
}
/* ── Render ── */
return (
<div className="glass rounded-xl p-5 space-y-5">
{/* ── Header ── */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Wallet className="w-4 h-4 text-brand-400" />
<h3 className="text-sm font-semibold text-white">Crypto Payment</h3>
</div>
{address && walletType && (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-700 text-surface-400">
{walletType}
</span>
)}
</div>
{!deployed ? (
/* ── Not deployed ── */
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-800/50 text-surface-400 text-xs">
<Shield className="w-3.5 h-3.5" />
Payment contract not deployed uploads are free
</div>
) : !address ? (
/* ── Connect wallet ── */
<button
onClick={handleConnect}
disabled={connecting}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
{connecting ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Connecting...</>
) : (
<><Wallet className="w-4 h-4" /> Connect Wallet</>
)}
</button>
) : wrongChain ? (
/* ── Wrong chain ── */
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs">
<XCircle className="w-3.5 h-3.5 shrink-0" />
Please switch to zkSync Local (chain 270)
</div>
) : (
/* ── Connected — show pricing ── */
<div className="space-y-4">
{/* Wallet address */}
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
<span className="text-xs text-surface-400">Connected</span>
<span className="text-xs font-mono text-surface-200">
{address.slice(0, 6)}...{address.slice(-4)}
</span>
</div>
{/* Free tier info */}
{userStats && (
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
<div className="flex items-center gap-2">
<Zap className="w-3.5 h-3.5 text-accent-cyan" />
<span className="text-xs text-surface-400">Free tier</span>
</div>
<span className="text-xs text-surface-300">
{formatBytes(userStats.remainingFree)} / {formatBytes(freeTierBytes)} remaining
</span>
</div>
)}
{/* Price info */}
{price && (
<div className="space-y-2">
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
<span className="text-xs text-surface-400">File size</span>
<span className="text-xs text-surface-200">{formatBytes(fileSize)}</span>
</div>
{price.isFree ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-accent-green/10 border border-accent-green/20 text-accent-green text-xs">
<CheckCircle className="w-3.5 h-3.5" />
Free upload (within free tier)
</div>
) : (
<>
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
<span className="text-xs text-surface-400">Price</span>
<span className="text-xs text-surface-200">{formatWeiToETH(price.totalWei, 6)} ETH</span>
</div>
{price.discountBps > 0 && (
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-accent-cyan/10 border border-accent-cyan/20">
<div className="flex items-center gap-2">
<Tag className="w-3 h-3 text-accent-cyan" />
<span className="text-xs text-accent-cyan">MAOS discount</span>
</div>
<span className="text-xs text-accent-cyan font-medium">-{price.discountBps / 100}%</span>
</div>
)}
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-brand-500/10 border border-brand-500/20">
<span className="text-xs font-medium text-surface-300">Final</span>
<span className="text-sm font-bold text-brand-400">
{formatWeiToETH(price.finalWei, 6)} ETH
</span>
</div>
{/* Token selector */}
{tokens.length > 1 && (
<div>
<label className="text-xs text-surface-400 mb-1.5 block">Pay with</label>
<div className="flex gap-1.5">
{tokens.filter(t => t.enabled || t.symbol === 'ETH').map(token => (
<button
key={token.symbol}
onClick={() => setSelectedToken(token.symbol)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
selectedToken === token.symbol
? 'bg-brand-500/20 text-brand-400 border border-brand-500/30'
: 'bg-surface-800 text-surface-400 border border-surface-700 hover:border-surface-500'
}`}
>
<span>{TOKEN_ICONS[token.symbol] || '●'}</span>
{token.symbol}
</button>
))}
</div>
</div>
)}
</>
)}
</div>
)}
{/* Action button */}
<button
onClick={handlePayAndUpload}
disabled={paying || uploading || status === 'complete'}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
{paying ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Processing payment...</>
) : uploading ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Uploading to IPFS...</>
) : status === 'complete' ? (
<><CheckCircle className="w-4 h-4" /> Complete</>
) : (
<><ArrowRight className="w-4 h-4" /> {price?.isFree ? 'Upload Free' : `Pay & Upload`}</>
)}
</button>
</div>
)}
{/* ── Result ── */}
{uploadResult && status === 'complete' && (
<div className="space-y-2 p-3 rounded-lg bg-accent-green/10 border border-accent-green/20 animate-fade-in">
<div className="flex items-center gap-2 text-accent-green">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-medium">Upload + Payment successful</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-surface-400">CID</span>
<button onClick={copyCID} className="flex items-center gap-1 text-surface-300 hover:text-surface-100 font-mono">
{uploadResult.cid.slice(0, 12)}...
{copied ? <CheckCircle className="w-3 h-3 text-accent-green" /> : <Copy className="w-3 h-3" />}
</button>
</div>
{txHash && (
<div className="flex items-center justify-between text-xs">
<span className="text-surface-400">Tx</span>
<a
href={`http://192.168.1.176:3050/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-brand-400 hover:text-brand-300 font-mono"
>
{txHash.slice(0, 10)}...
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
)}
{/* ── Error ── */}
{error && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-xs animate-fade-in">
<XCircle className="w-3.5 h-3.5 shrink-0" />
{error}
</div>
)}
{/* ── MAOS Discount tiers ── */}
{address && discountTiers.length > 0 && (
<details className="text-xs text-surface-500">
<summary className="cursor-pointer hover:text-surface-400 transition-colors">
MAOS discount tiers
</summary>
<div className="mt-2 space-y-1">
{discountTiers.map((tier, i) => (
<div key={i} className="flex justify-between px-2 py-1 rounded bg-surface-800/30">
<span>{formatWeiToETH(tier.minBalance, 0)} MAOS</span>
<span className="text-accent-cyan">{tier.discountBps / 100}% off</span>
</div>
))}
</div>
</details>
)}
</div>
);
}
+6 -2
View File
@@ -1,10 +1,13 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { checkHealth } from '@/lib/api'; import { checkHealth } from '@/lib/api/gateway';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import { Shield, LogOut } from 'lucide-react'; import { Shield, LogOut } from 'lucide-react';
// NOTE: PortalHeader fetches node status + auth internally via useEffect.
// Pages that need a custom header can pass one via PortalLayout's `header` slot
// instead of modifying this component. See layout-portal.tsx PortalLayoutProps.
export default function PortalHeader() { export default function PortalHeader() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [nodeStatus, setNodeStatus] = useState<'online' | 'offline' | 'checking'>('checking'); const [nodeStatus, setNodeStatus] = useState<'online' | 'offline' | 'checking'>('checking');
@@ -18,7 +21,8 @@ export default function PortalHeader() {
if (!mounted) return; if (!mounted) return;
setNodeStatus('online'); setNodeStatus('online');
setNodeVersion(h.node); setNodeVersion(h.node);
} catch { } catch (err) {
console.error('[PortalHeader] Health check failed:', err);
if (!mounted) return; if (!mounted) return;
setNodeStatus('offline'); setNodeStatus('offline');
} }
+22 -7
View File
@@ -20,7 +20,22 @@ import {
Moon, Moon,
} from 'lucide-react'; } from 'lucide-react';
const NAV_ITEMS = [ interface NavItem {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
interface PortalSidebarProps {
/** Custom nav items. Defaults to full portal nav if omitted. */
navItems?: NavItem[];
/** Theme toggle callback. Falls back to internal useTheme if omitted. */
onThemeToggle?: () => void;
/** Disconnect callback. Falls back to window.location.href='/' if omitted. */
onDisconnect?: () => void;
}
const DEFAULT_NAV_ITEMS: NavItem[] = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/usage', label: 'Usage', icon: BarChart3 }, { href: '/usage', label: 'Usage', icon: BarChart3 },
{ href: '/upload', label: 'Upload', icon: Upload }, { href: '/upload', label: 'Upload', icon: Upload },
@@ -34,13 +49,13 @@ const NAV_ITEMS = [
{ href: '/settings', label: 'Settings', icon: Settings }, { href: '/settings', label: 'Settings', icon: Settings },
]; ];
export default function PortalSidebar() { export default function PortalSidebar({ navItems, onThemeToggle, onDisconnect }: PortalSidebarProps = {}) {
const pathname = usePathname(); const pathname = usePathname();
const { theme, toggle: toggleTheme } = useTheme(); const { theme, toggle: toggleTheme } = useTheme();
function handleDisconnect() { const items = navItems ?? DEFAULT_NAV_ITEMS;
window.location.href = '/'; const handleThemeToggle = onThemeToggle ?? toggleTheme;
} const handleDisconnect = onDisconnect ?? (() => { window.location.href = '/'; });
return ( return (
<aside aria-label="Main navigation" className="fixed left-0 top-0 bottom-0 w-56 flex flex-col glass border-r border-surface-800 z-50"> <aside aria-label="Main navigation" className="fixed left-0 top-0 bottom-0 w-56 flex flex-col glass border-r border-surface-800 z-50">
@@ -54,7 +69,7 @@ export default function PortalSidebar() {
{/* Nav */} {/* Nav */}
<nav className="flex-1 p-3 space-y-1 overflow-y-auto"> <nav className="flex-1 p-3 space-y-1 overflow-y-auto">
{NAV_ITEMS.map((item) => { {items.map((item) => {
const active = pathname === item.href; const active = pathname === item.href;
return ( return (
<Link <Link
@@ -76,7 +91,7 @@ export default function PortalSidebar() {
{/* Theme toggle + Disconnect */} {/* Theme toggle + Disconnect */}
<div className="p-3 border-t border-surface-800 space-y-1"> <div className="p-3 border-t border-surface-800 space-y-1">
<button <button
onClick={toggleTheme} onClick={handleThemeToggle}
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-surface-500 hover:text-surface-200 hover:bg-surface-800/50 transition-all duration-150" className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-surface-500 hover:text-surface-200 hover:bg-surface-800/50 transition-all duration-150"
> >
{theme === 'dark' ? <Sun className="w-4 h-4 shrink-0" /> : <Moon className="w-4 h-4 shrink-0" />} {theme === 'dark' ? <Sun className="w-4 h-4 shrink-0" /> : <Moon className="w-4 h-4 shrink-0" />}
+2 -2
View File
@@ -9,8 +9,8 @@ import { useEffect } from 'react';
export default function SWRegister() { export default function SWRegister() {
useEffect(() => { useEffect(() => {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => { navigator.serviceWorker.register('/sw.js').catch((err) => {
// SW registratie mislukt — geen offline, app werkt gewoon console.error('[SWRegister] Registration failed:', err);
}); });
} }
}, []); }, []);
-234
View File
@@ -1,234 +0,0 @@
'use client';
/* ════════════════════════════ SearchBar ════════════════════════════
*
* Reusable search bar met debounce, resultaten dropdown, en toetsenbordnavigatie.
* Gebruikt de client-side search index (search-index.ts) of een custom onSearch callback.
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { Search, X, FileIcon, FolderIcon, Loader2 } from 'lucide-react';
import { searchIndex, type SearchResult } from '@/lib/search-index';
/* ════════════════════════════ Types ════════════════════════════ */
export interface SearchBarProps {
/** External search handler (optioneel — gebruikt search-index.ts anders) */
onSearch?: (query: string) => Promise<SearchResult[]> | SearchResult[];
/** Called when user selects a result */
onSelect: (result: SearchResult) => void;
/** Placeholder text */
placeholder?: string;
/** Auto-focus on mount */
autoFocus?: boolean;
/** Extra CSS classes */
className?: string;
}
/* ════════════════════════════ Component ════════════════════════════ */
export default function SearchBar({
onSearch,
onSelect,
placeholder = 'Search files, CIDs, and content…',
autoFocus = false,
className = '',
}: SearchBarProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [showResults, setShowResults] = useState(false);
const [selectedIdx, setSelectedIdx] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
/* ── Search logic ── */
const doSearch = useCallback(async (q: string) => {
if (!q || q.trim().length < 2) {
setResults([]);
setShowResults(false);
return;
}
setLoading(true);
try {
const hits = onSearch
? await onSearch(q)
: searchIndex(q);
setResults(hits);
setShowResults(true);
setSelectedIdx(-1);
} finally {
setLoading(false);
}
}, [onSearch]);
/* ── Debounced input ── */
function handleChange(value: string) {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(value), 250);
}
/* ── Keyboard navigation ── */
function handleKeyDown(e: React.KeyboardEvent) {
if (!showResults || results.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIdx((prev) => Math.min(prev + 1, results.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIdx((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (selectedIdx >= 0 && selectedIdx < results.length) {
handleSelect(results[selectedIdx]);
}
break;
case 'Escape':
setShowResults(false);
inputRef.current?.blur();
break;
}
}
/* ── Select ── */
function handleSelect(result: SearchResult) {
setShowResults(false);
setQuery('');
onSelect(result);
}
/* ── Click outside ── */
useEffect(() => {
function handleClick(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setShowResults(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
/* ── Cleanup ── */
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ════════════════════════════ Render ════════════════════════════ */
return (
<div ref={panelRef} className={`relative ${className}`}>
{/* Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 pointer-events-none" />
<input
ref={inputRef}
value={query}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => { if (results.length > 0) setShowResults(true); }}
placeholder={placeholder}
className="w-full pl-10 pr-10 py-2.5 rounded-xl bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/50 focus-visible:border-brand-500 transition-colors"
autoFocus={autoFocus}
role="combobox"
aria-expanded={showResults}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-controls="search-results-listbox"
/>
{loading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 animate-spin" />
)}
{!loading && query && (
<button
onClick={() => { setQuery(''); setResults([]); setShowResults(false); inputRef.current?.focus(); }}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-700 transition-colors"
>
<X className="w-4 h-4 text-surface-500" />
</button>
)}
</div>
{/* Live region for screen readers */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{showResults ? `${results.length} resultaten` : ''}
</div>
{/* Results dropdown */}
{showResults && (
<div
id="search-results-listbox"
role="listbox"
className="absolute top-full left-0 right-0 mt-2 glass rounded-xl overflow-hidden shadow-xl z-50 max-h-72 overflow-y-auto animate-fade-in">
{results.length === 0 && !loading ? (
<div className="p-4 text-sm text-surface-500 text-center">No results found</div>
) : (
results.map((result, idx) => (
<button
key={result.entry.cid + result.matchField}
id={`search-result-${idx}`}
role="option"
aria-selected={idx === selectedIdx}
onClick={() => handleSelect(result)}
onMouseEnter={() => setSelectedIdx(idx)}
className={`w-full flex items-start gap-3 px-4 py-3 text-left text-sm transition-colors ${
idx === selectedIdx ? 'bg-surface-700/50' : 'hover:bg-surface-800/50'
}`}
>
{result.entry.type === 'dir'
? <FolderIcon className="w-4 h-4 text-accent-amber shrink-0 mt-0.5" />
: <FileIcon className="w-4 h-4 text-accent-blue shrink-0 mt-0.5" />
}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-surface-200 font-medium truncate">
{result.entry.name || result.entry.cid.slice(0, 20) + '…'}
</span>
{result.matchField === 'name' && (
<span className="text-[10px] text-brand-400 bg-brand-500/10 px-1.5 py-0.5 rounded-full shrink-0">Name</span>
)}
{result.matchField === 'cid' && (
<span className="text-[10px] text-accent-cyan bg-accent-cyan/10 px-1.5 py-0.5 rounded-full shrink-0">CID</span>
)}
</div>
{result.entry.text && (
<p className="text-xs text-surface-500 mt-0.5 line-clamp-1">
{result.entry.text.slice(0, 120)}
</p>
)}
<p className="text-[11px] text-surface-600 font-mono mt-0.5">
{result.entry.cid.slice(0, 16)}
</p>
</div>
<span className="text-[11px] text-surface-500 shrink-0">
{result.entry.size > 0
? result.entry.size < 1024
? `${result.entry.size} B`
: `${(result.entry.size / 1024).toFixed(1)} KB`
: ''}
</span>
</button>
))
)}
</div>
)}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
'use client';
import { Wallet, Loader2, XCircle, Shield } from 'lucide-react';
interface PaymentHeaderProps {
deployed: boolean;
address: string | null;
walletType: string | null;
connecting: boolean;
wrongChain: boolean;
onConnect: () => void;
}
export default function PaymentHeader({
deployed,
address,
walletType,
connecting,
wrongChain,
onConnect,
}: PaymentHeaderProps) {
return (
<>
{/* ── Header ── */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Wallet className="w-4 h-4 text-brand-400" />
<h3 className="text-sm font-semibold text-white">Crypto Payment</h3>
</div>
{address && walletType && (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-700 text-surface-400">
{walletType}
</span>
)}
</div>
{!deployed ? (
/* ── Not deployed ── */
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-800/50 text-surface-400 text-xs">
<Shield className="w-3.5 h-3.5" />
Payment contract not deployed uploads are free
</div>
) : !address ? (
/* ── Connect wallet ── */
<button
onClick={onConnect}
disabled={connecting}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
{connecting ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Connecting...</>
) : (
<><Wallet className="w-4 h-4" /> Connect Wallet</>
)}
</button>
) : wrongChain ? (
/* ── Wrong chain ── */
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs">
<XCircle className="w-3.5 h-3.5 shrink-0" />
Please switch to zkSync Local (chain 270)
</div>
) : (
/* ── Connected wallet address ── */
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
<span className="text-xs text-surface-400">Connected</span>
<span className="text-xs font-mono text-surface-200">
{address.slice(0, 6)}...{address.slice(-4)}
</span>
</div>
)}
</>
);
}
+206
View File
@@ -0,0 +1,206 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { usePaymentService, type PriceInfo, type TokenInfo, type MAOSDiscountTier, type UserUploadStats } from '@/lib/payment';
import {
connectWallet, switchToChain270, getInjectedProvider,
type WalletProvider,
} from '@/lib/wallet';
import PaymentHeader from './PaymentHeader';
import PricingDisplay from './PricingDisplay';
import PaymentResult from './PaymentResult';
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) {
const paymentService = usePaymentService();
// Wallet state
const [address, setAddress] = useState<string | null>(null);
const [provider, setProvider] = useState<WalletProvider | null>(null);
const [connecting, setConnecting] = useState(false);
const [walletType, setWalletType] = useState<string | null>(null);
const [wrongChain, setWrongChain] = useState(false);
// Contract state
const [deployed, setDeployed] = useState(false);
const [price, setPrice] = useState<PriceInfo | null>(null);
const [userStats, setUserStats] = useState<UserUploadStats | null>(null);
const [tokens, setTokens] = useState<TokenInfo[]>([]);
const [discountTiers, setDiscountTiers] = useState<MAOSDiscountTier[]>([]);
const [selectedToken, setSelectedToken] = useState('ETH');
const [freeTierBytes, setFreeTierBytes] = useState<bigint>(BigInt(0));
// Upload state
const [uploading, setUploading] = useState(false);
const [paying, setPaying] = useState(false);
const [status, setStatus] = useState<'idle' | 'paying' | 'uploading' | 'complete' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const [txHash, setTxHash] = useState<string | null>(null);
const [uploadResult, setUploadResult] = useState<{ cid: string; size: number } | null>(null);
// Init
useEffect(() => {
if (contractAddress) {
paymentService.setContractAddress(contractAddress);
}
paymentService.isDeployed().then(setDeployed).catch((err) => { console.error('[PaymentPanel] Deploy check failed:', err); setDeployed(false); });
}, [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
if (window.maosv6) setWalletType('MAOS Wallet');
else if (window.ethereum?.isRabby) setWalletType('Rabby');
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: unknown) {
setError(e instanceof Error ? 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) ** BigInt(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: unknown) {
setError(e instanceof Error ? e.message : 'Payment/upload failed');
setStatus('error');
} finally {
setPaying(false);
}
}
/* ── Render ── */
return (
<div className="glass rounded-xl p-5 space-y-5">
<PaymentHeader
deployed={deployed}
address={address}
walletType={walletType}
connecting={connecting}
wrongChain={wrongChain}
onConnect={handleConnect}
/>
{deployed && address && !wrongChain && (
<PricingDisplay
price={price}
userStats={userStats}
tokens={tokens}
discountTiers={discountTiers}
selectedToken={selectedToken}
freeTierBytes={freeTierBytes}
fileSize={fileSize}
status={status}
paying={paying}
uploading={uploading}
address={address}
onSelectToken={setSelectedToken}
onPayAndUpload={handlePayAndUpload}
/>
)}
<PaymentResult
uploadResult={uploadResult}
txHash={txHash}
status={status}
error={error}
/>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
'use client';
import { useState } from 'react';
import { CheckCircle, XCircle, Copy, ExternalLink } from 'lucide-react';
import { zkSyncExplorerUrl } from '@/lib/config';
interface PaymentResultProps {
uploadResult: { cid: string; size: number } | null;
txHash: string | null;
status: 'idle' | 'paying' | 'uploading' | 'complete' | 'error';
error: string | null;
}
export default function PaymentResult({
uploadResult,
txHash,
status,
error,
}: PaymentResultProps) {
const [copied, setCopied] = useState(false);
async function copyCID() {
if (!uploadResult) return;
try {
await navigator.clipboard.writeText(uploadResult.cid);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) { console.error('[PaymentPanel] Clipboard write failed:', err); }
}
return (
<>
{/* ── Success ── */}
{uploadResult && status === 'complete' && (
<div className="space-y-2 p-3 rounded-lg bg-accent-green/10 border border-accent-green/20 animate-fade-in">
<div className="flex items-center gap-2 text-accent-green">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-medium">Upload + Payment successful</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-surface-400">CID</span>
<button onClick={copyCID} className="flex items-center gap-1 text-surface-300 hover:text-surface-100 font-mono">
{uploadResult.cid.slice(0, 12)}...
{copied ? <CheckCircle className="w-3 h-3 text-accent-green" /> : <Copy className="w-3 h-3" />}
</button>
</div>
{txHash && (
<div className="flex items-center justify-between text-xs">
<span className="text-surface-400">Tx</span>
<a
href={`${zkSyncExplorerUrl()}/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-brand-400 hover:text-brand-300 font-mono"
>
{txHash.slice(0, 10)}...
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
)}
{/* ── Error ── */}
{error && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-xs animate-fade-in">
<XCircle className="w-3.5 h-3.5 shrink-0" />
{error}
</div>
)}
</>
);
}
+160
View File
@@ -0,0 +1,160 @@
'use client';
import { formatBytes } from '@/lib/helpers';
import { formatWeiToETH } from '@/lib/wallet';
import { Zap, CheckCircle, Tag, ArrowRight, Loader2 } from 'lucide-react';
import type { PriceInfo, TokenInfo, MAOSDiscountTier, UserUploadStats } from '@/lib/payment';
/* ── Token icons ── */
const TOKEN_ICONS: Record<string, string> = {
ETH: '⟠',
MAOS: 'Ⓜ',
USDC: '⬡',
};
interface PricingDisplayProps {
price: PriceInfo | null;
userStats: UserUploadStats | null;
tokens: TokenInfo[];
discountTiers: MAOSDiscountTier[];
selectedToken: string;
freeTierBytes: bigint;
fileSize: number;
status: 'idle' | 'paying' | 'uploading' | 'complete' | 'error';
paying: boolean;
uploading: boolean;
address: string | null;
onSelectToken: (symbol: string) => void;
onPayAndUpload: () => void;
}
export default function PricingDisplay({
price,
userStats,
tokens,
discountTiers,
selectedToken,
freeTierBytes,
fileSize,
status,
paying,
uploading,
address,
onSelectToken,
onPayAndUpload,
}: PricingDisplayProps) {
return (
<div className="space-y-4">
{/* Free tier info */}
{userStats && (
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
<div className="flex items-center gap-2">
<Zap className="w-3.5 h-3.5 text-accent-cyan" />
<span className="text-xs text-surface-400">Free tier</span>
</div>
<span className="text-xs text-surface-300">
{formatBytes(userStats.remainingFree)} / {formatBytes(freeTierBytes)} remaining
</span>
</div>
)}
{/* Price info */}
{price && (
<div className="space-y-2">
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
<span className="text-xs text-surface-400">File size</span>
<span className="text-xs text-surface-200">{formatBytes(fileSize)}</span>
</div>
{price.isFree ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-accent-green/10 border border-accent-green/20 text-accent-green text-xs">
<CheckCircle className="w-3.5 h-3.5" />
Free upload (within free tier)
</div>
) : (
<>
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface-800/50">
<span className="text-xs text-surface-400">Price</span>
<span className="text-xs text-surface-200">{formatWeiToETH(price.totalWei, 6)} ETH</span>
</div>
{price.discountBps > 0 && (
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-accent-cyan/10 border border-accent-cyan/20">
<div className="flex items-center gap-2">
<Tag className="w-3 h-3 text-accent-cyan" />
<span className="text-xs text-accent-cyan">MAOS discount</span>
</div>
<span className="text-xs text-accent-cyan font-medium">-{price.discountBps / 100}%</span>
</div>
)}
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-brand-500/10 border border-brand-500/20">
<span className="text-xs font-medium text-surface-300">Final</span>
<span className="text-sm font-bold text-brand-400">
{formatWeiToETH(price.finalWei, 6)} ETH
</span>
</div>
{/* Token selector */}
{tokens.length > 1 && (
<div>
<label className="text-xs text-surface-400 mb-1.5 block">Pay with</label>
<div className="flex gap-1.5">
{tokens.filter(t => t.enabled || t.symbol === 'ETH').map(token => (
<button
key={token.symbol}
onClick={() => onSelectToken(token.symbol)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
selectedToken === token.symbol
? 'bg-brand-500/20 text-brand-400 border border-brand-500/30'
: 'bg-surface-800 text-surface-400 border border-surface-700 hover:border-surface-500'
}`}
>
<span>{TOKEN_ICONS[token.symbol] || '●'}</span>
{token.symbol}
</button>
))}
</div>
</div>
)}
</>
)}
</div>
)}
{/* Action button */}
<button
onClick={onPayAndUpload}
disabled={paying || uploading || status === 'complete'}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
{paying ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Processing payment...</>
) : uploading ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Uploading to IPFS...</>
) : status === 'complete' ? (
<><CheckCircle className="w-4 h-4" /> Complete</>
) : (
<><ArrowRight className="w-4 h-4" /> {price?.isFree ? 'Upload Free' : 'Pay & Upload'}</>
)}
</button>
{/* ── MAOS Discount tiers ── */}
{address && discountTiers.length > 0 && (
<details className="text-xs text-surface-500">
<summary className="cursor-pointer hover:text-surface-400 transition-colors">
MAOS discount tiers
</summary>
<div className="mt-2 space-y-1">
{discountTiers.map((tier, i) => (
<div key={i} className="flex justify-between px-2 py-1 rounded bg-surface-800/30">
<span>{formatWeiToETH(tier.minBalance, 0)} MAOS</span>
<span className="text-accent-cyan">{tier.discountBps / 100}% off</span>
</div>
))}
</div>
</details>
)}
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
import PaymentPanel from './PaymentPanel';
export default PaymentPanel;
export { PaymentPanel };
+147
View File
@@ -0,0 +1,147 @@
'use client';
/* ════════════════════════════ SearchBar ════════════════════════════
*
* Orchestrator — composes SearchInput + SearchResultsList.
* Owns search state and wires callbacks between child components.
*/
import { useState, useRef, useCallback, useEffect } from "react";
import { searchIndex, type SearchResult } from "@/lib/search-index";
import SearchInput, { type SearchInputHandle } from "./SearchInput";
import SearchResultsList from "./SearchResultsList";
/* ════════════════════════════ Types ════════════════════════════ */
export interface SearchBarProps {
/** External search handler (optioneel — gebruikt search-index.ts anders) */
onSearch?: (query: string) => Promise<SearchResult[]> | SearchResult[];
/** Called when user selects a result */
onSelect: (result: SearchResult) => void;
/** Placeholder text */
placeholder?: string;
/** Auto-focus on mount */
autoFocus?: boolean;
/** Extra CSS classes */
className?: string;
}
/* ════════════════════════════ Component ════════════════════════════ */
export default function SearchBar({
onSearch,
onSelect,
placeholder,
autoFocus,
className = "",
}: SearchBarProps) {
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [showResults, setShowResults] = useState(false);
const [selectedIdx, setSelectedIdx] = useState(-1);
const searchInputRef = useRef<SearchInputHandle>(null);
const panelRef = useRef<HTMLDivElement>(null);
/* ── Search logic ── */
const doSearch = useCallback(async (q: string) => {
if (!q || q.trim().length < 2) {
setResults([]);
setShowResults(false);
return;
}
setLoading(true);
try {
const hits = onSearch
? await onSearch(q)
: searchIndex(q);
setResults(hits);
setShowResults(true);
setSelectedIdx(-1);
} finally {
setLoading(false);
}
}, [onSearch]);
/* ── Keyboard navigation ── */
function handleKeyDown(e: React.KeyboardEvent) {
if (!showResults || results.length === 0) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIdx((prev) => Math.min(prev + 1, results.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setSelectedIdx((prev) => Math.max(prev - 1, 0));
break;
case "Enter":
e.preventDefault();
if (selectedIdx >= 0 && selectedIdx < results.length) {
handleSelect(results[selectedIdx]);
}
break;
case "Escape":
setShowResults(false);
searchInputRef.current?.blur();
break;
}
}
/* ── Select ── */
function handleSelect(result: SearchResult) {
setShowResults(false);
searchInputRef.current?.clear();
onSelect(result);
}
/* ── Click outside ── */
useEffect(() => {
function handleClick(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setShowResults(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
/* ── Render ── */
return (
<div ref={panelRef} className={`relative ${className}`}>
<SearchInput
ref={searchInputRef}
onSearch={doSearch}
onKeyDown={handleKeyDown}
onFocus={() => { if (results.length > 0) setShowResults(true); }}
loading={loading}
hasResults={showResults}
placeholder={placeholder}
autoFocus={autoFocus}
/>
{/* Live region for screen readers */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{showResults ? `${results.length} resultaten` : ""}
</div>
{/* Results dropdown */}
{showResults && (
<SearchResultsList
results={results}
selectedIdx={selectedIdx}
loading={loading}
onSelect={handleSelect}
onMouseEnter={setSelectedIdx}
/>
)}
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
'use client';
/* ── SearchInput ──
*
* Controlled search input with search icon, debounced search,
* loading spinner, clear button, and keyboard navigation handler.
*/
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react";
import { Search, X, Loader2 } from "lucide-react";
/* ── Types ── */
export interface SearchInputHandle {
clear: () => void;
focus: () => void;
blur: () => void;
}
interface SearchInputProps {
/** Called (debounced) when the user types */
onSearch: (query: string) => void;
/** Keyboard navigation handler (from parent orchestrator) */
onKeyDown: (e: React.KeyboardEvent) => void;
/** Called when input receives focus */
onFocus?: () => void;
/** Whether a search is in progress */
loading: boolean;
/** Whether there are results to show on focus */
hasResults: boolean;
/** Placeholder text */
placeholder?: string;
/** Auto-focus on mount */
autoFocus?: boolean;
}
/* ── Component ── */
const SearchInput = forwardRef<SearchInputHandle, SearchInputProps>(function SearchInput(
{ onSearch, onKeyDown, onFocus, loading, hasResults, placeholder = "Search files, CIDs, and content…", autoFocus = false },
ref,
) {
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/* Expose clear + focus to parent */
useImperativeHandle(ref, () => ({
clear() {
setQuery("");
},
focus() {
inputRef.current?.focus();
},
blur() {
inputRef.current?.blur();
},
}));
/* ── Debounced input ── */
function handleChange(value: string) {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => onSearch(value), 250);
}
/* ── Cleanup ── */
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [autoFocus]);
/* ── Render ── */
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 pointer-events-none" />
<input
ref={inputRef}
value={query}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={onKeyDown}
onFocus={() => {
if (hasResults) onFocus?.();
}}
placeholder={placeholder}
className="w-full pl-10 pr-10 py-2.5 rounded-xl bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/50 focus-visible:border-brand-500 transition-colors"
autoFocus={autoFocus}
role="combobox"
aria-expanded={hasResults}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-controls="search-results-listbox"
/>
{loading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 animate-spin" />
)}
{!loading && query && (
<button
onClick={() => {
setQuery("");
if (debounceRef.current) clearTimeout(debounceRef.current);
inputRef.current?.focus();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-700 transition-colors"
>
<X className="w-4 h-4 text-surface-500" />
</button>
)}
</div>
);
});
export default SearchInput;
@@ -0,0 +1,77 @@
'use client';
/* ── SearchResultItem ──
*
* Single search result row: icon, name with match badge, text excerpt,
* CID, and file size.
*/
import { FileIcon, FolderIcon } from "lucide-react";
import type { SearchResult } from "@/lib/search-index";
/* ── Types ── */
export interface SearchResultItemProps {
result: SearchResult;
selected: boolean;
onSelect: (result: SearchResult) => void;
onMouseEnter: () => void;
index: number;
}
/* ── Component ── */
export default function SearchResultItem({
result,
selected,
onSelect,
onMouseEnter,
index,
}: SearchResultItemProps) {
return (
<button
key={result.entry.cid + result.matchField}
id={`search-result-${index}`}
role="option"
aria-selected={selected}
onClick={() => onSelect(result)}
onMouseEnter={onMouseEnter}
className={`w-full flex items-start gap-3 px-4 py-3 text-left text-sm transition-colors ${
selected ? "bg-surface-700/50" : "hover:bg-surface-800/50"
}`}
>
{result.entry.type === "dir"
? <FolderIcon className="w-4 h-4 text-accent-amber shrink-0 mt-0.5" />
: <FileIcon className="w-4 h-4 text-accent-blue shrink-0 mt-0.5" />
}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-surface-200 font-medium truncate">
{result.entry.name || result.entry.cid.slice(0, 20) + "…"}
</span>
{result.matchField === "name" && (
<span className="text-[10px] text-brand-400 bg-brand-500/10 px-1.5 py-0.5 rounded-full shrink-0">Name</span>
)}
{result.matchField === "cid" && (
<span className="text-[10px] text-accent-cyan bg-accent-cyan/10 px-1.5 py-0.5 rounded-full shrink-0">CID</span>
)}
</div>
{result.entry.text && (
<p className="text-xs text-surface-500 mt-0.5 line-clamp-1">
{result.entry.text.slice(0, 120)}
</p>
)}
<p className="text-[11px] text-surface-600 font-mono mt-0.5">
{result.entry.cid.slice(0, 16)}
</p>
</div>
<span className="text-[11px] text-surface-500 shrink-0">
{result.entry.size > 0
? result.entry.size < 1024
? `${result.entry.size} B`
: `${(result.entry.size / 1024).toFixed(1)} KB`
: ""}
</span>
</button>
);
}
@@ -0,0 +1,53 @@
'use client';
/* ── SearchResultsList ──
*
* Results dropdown with keyboard highlighting and aria roles.
* Click-outside is handled by the parent orchestrator via onClose.
*/
import SearchResultItem from "./SearchResultItem";
import type { SearchResult } from "@/lib/search-index";
/* ── Types ── */
export interface SearchResultsListProps {
results: SearchResult[];
selectedIdx: number;
loading: boolean;
onSelect: (result: SearchResult) => void;
onMouseEnter: (idx: number) => void;
}
/* ── Component ── */
export default function SearchResultsList({
results,
selectedIdx,
loading,
onSelect,
onMouseEnter,
}: SearchResultsListProps) {
return (
<div
id="search-results-listbox"
role="listbox"
className="absolute top-full left-0 right-0 mt-2 glass rounded-xl overflow-hidden shadow-xl z-50 max-h-72 overflow-y-auto animate-fade-in"
>
{results.length === 0 && !loading ? (
<div className="p-4 text-sm text-surface-500 text-center">No results found</div>
) : (
results.map((result, idx) => (
<SearchResultItem
key={result.entry.cid + result.matchField}
result={result}
selected={idx === selectedIdx}
onSelect={onSelect}
onMouseEnter={() => onMouseEnter(idx)}
index={idx}
/>
))
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
/* ── Search barrel ── */
export { default } from "./SearchBar";
export { default as SearchInput } from "./SearchInput";
export { default as SearchResultsList } from "./SearchResultsList";
export { default as SearchResultItem } from "./SearchResultItem";
export type { SearchBarProps } from "./SearchBar";
export type { SearchInputHandle } from "./SearchInput";
export type { SearchResultsListProps } from "./SearchResultsList";
export type { SearchResultItemProps } from "./SearchResultItem";
+483
View File
@@ -0,0 +1,483 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
getNodeInfo,
getPeers,
listPins,
addPin,
removePin,
uploadFile,
listFiles,
listUsers,
createUser,
deleteUser,
explorerLs,
explorerCat,
explorerStat,
listIPNSKeys,
ipnsPublish,
ipnsResolve,
ipnsGenKey,
getRepoStats,
getBWStats,
checkHealth,
} from '../api';
function mockFetch(response: unknown, ok = true, status = 200, statusText = 'OK') {
return vi.fn().mockResolvedValue({
ok,
status,
statusText,
json: vi.fn().mockResolvedValue(response),
text: vi.fn().mockResolvedValue(typeof response === 'string' ? response : JSON.stringify(response)),
});
}
function mockFetchText(text: string, ok = true) {
return vi.fn().mockResolvedValue({
ok,
json: vi.fn().mockRejectedValue(new Error('not json')),
text: vi.fn().mockResolvedValue(text),
});
}
describe('getNodeInfo', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('calls /api/node/info and returns mapped response', async () => {
const data = { version: '1.0', peerId: '12D3Koo...', peers: 5, storageUsed: '1.2 GB', storageMax: '30 GB', uptime: '2h' };
globalThis.fetch = mockFetch(data);
const result = await getNodeInfo();
expect(result).toEqual(data);
expect(globalThis.fetch).toHaveBeenCalledWith('/api/node/info', expect.objectContaining({ credentials: 'include' }));
});
it('throws on non-ok response', async () => {
globalThis.fetch = mockFetch({ error: 'not found' }, false, 404, 'Not Found');
await expect(getNodeInfo()).rejects.toThrow('404');
});
});
describe('getPeers', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('transforms Peers array to flat objects', async () => {
globalThis.fetch = mockFetch({
Peers: [
{ Peer: 'peer1', Addr: '/ip4/1.2.3.4', Latency: '10ms' },
{ Peer: 'peer2', Addr: '/ip4/5.6.7.8', Latency: '' },
],
});
const result = await getPeers();
expect(result).toEqual([
{ id: 'peer1', addr: '/ip4/1.2.3.4', latency: '10ms' },
{ id: 'peer2', addr: '/ip4/5.6.7.8', latency: '—' },
]);
});
it('returns empty array when no Peers key', async () => {
globalThis.fetch = mockFetch({});
const result = await getPeers();
expect(result).toEqual([]);
});
});
describe('listPins', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('transforms Keys object to IPFSPin array', async () => {
globalThis.fetch = mockFetch({
Keys: {
QmA: { Type: 'recursive' },
QmB: { Type: 'direct' },
},
});
const result = await listPins();
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ cid: 'QmA', name: '', size: 0, created: 'recursive' });
expect(result[1]).toEqual({ cid: 'QmB', name: '', size: 0, created: 'direct' });
});
it('returns empty array when no Keys', async () => {
globalThis.fetch = mockFetch({});
const result = await listPins();
expect(result).toEqual([]);
});
});
describe('addPin', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('POSTs with cid and name in body', async () => {
globalThis.fetch = mockFetch({ cid: 'QmNew' });
const result = await addPin('QmNew', 'myfile.txt');
expect(result).toEqual({ cid: 'QmNew' });
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/pins',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ cid: 'QmNew', name: 'myfile.txt' }),
}),
);
});
it('POSTs with cid only when no name', async () => {
globalThis.fetch = mockFetch({ cid: 'QmNew' });
const result = await addPin('QmNew');
expect(result).toEqual({ cid: 'QmNew' });
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/pins',
expect.objectContaining({
body: JSON.stringify({ cid: 'QmNew', name: undefined }),
}),
);
});
});
describe('removePin', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('sends DELETE request', async () => {
globalThis.fetch = mockFetch({});
await removePin('QmToRemove');
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/pins/QmToRemove',
expect.objectContaining({ method: 'DELETE' }),
);
});
});
describe('uploadFile', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('sends FormData and normalizes Kubo-style response', async () => {
globalThis.fetch = mockFetch({ Hash: 'QmUploaded', Size: 12345 });
const file = new File(['test'], 'test.txt');
const result = await uploadFile(file);
expect(result).toEqual({ cid: 'QmUploaded', size: 12345 });
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/files/upload',
expect.objectContaining({ method: 'POST', credentials: 'include' }),
);
const callArgs = (globalThis.fetch as any).mock.calls[0];
expect(callArgs[1].body).toBeInstanceOf(FormData);
});
it('normalizes cid/hash fields', async () => {
globalThis.fetch = mockFetch({ cid: 'QmCid', size: 999 });
const file = new File(['test'], 'test.txt');
const result = await uploadFile(file);
expect(result).toEqual({ cid: 'QmCid', size: 999 });
});
it('throws on upload failure', async () => {
globalThis.fetch = mockFetch({}, false, 500, 'Server Error');
const file = new File(['test'], 'test.txt');
await expect(uploadFile(file)).rejects.toThrow('Upload failed');
});
});
describe('listFiles', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('returns file list from API', async () => {
const files = [
{ name: 'a.txt', cid: 'QmA', size: 100, modified: '2025-01-01' },
];
globalThis.fetch = mockFetch(files);
const result = await listFiles();
expect(result).toEqual(files);
});
});
describe('listUsers', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('handles array response format', async () => {
const users = [{ username: 'alice', created: '2025-01-01', active: true }];
globalThis.fetch = mockFetch(users);
const result = await listUsers();
expect(result).toEqual(users);
});
it('handles {users: [...]} response format', async () => {
const users = [{ username: 'bob', created: '2025-02-01', active: false }];
globalThis.fetch = mockFetch({ users });
const result = await listUsers();
expect(result).toEqual(users);
});
it('returns empty array for unknown format', async () => {
globalThis.fetch = mockFetch({ notUsers: 'nope' });
const result = await listUsers();
expect(result).toEqual([]);
});
});
describe('createUser', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('POSTs username and password', async () => {
globalThis.fetch = mockFetch({ username: 'newuser' });
const result = await createUser('newuser', 'secret123');
expect(result).toEqual({ username: 'newuser' });
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/users',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ username: 'newuser', password: 'secret123' }),
}),
);
});
});
describe('deleteUser', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('sends DELETE request', async () => {
globalThis.fetch = mockFetch({});
await deleteUser('alice');
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/users/alice',
expect.objectContaining({ method: 'DELETE' }),
);
});
it('encodes special characters in username', async () => {
globalThis.fetch = mockFetch({});
await deleteUser('user@name');
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/users/user%40name',
expect.anything(),
);
});
});
describe('explorerLs', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('POSTs to explorer/ls endpoint', async () => {
const entries = [{ name: 'file.txt', type: 'file', size: 100, hash: 'QmX' }];
globalThis.fetch = mockFetch(entries);
const result = await explorerLs('QmDir');
expect(result).toEqual(entries);
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/explorer/ls/QmDir',
expect.objectContaining({ method: 'POST' }),
);
});
});
describe('explorerCat', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('returns text content (not JSON)', async () => {
globalThis.fetch = mockFetchText('hello world');
const result = await explorerCat('QmFile');
expect(result).toBe('hello world');
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/explorer/cat/QmFile',
expect.objectContaining({ method: 'POST', credentials: 'include' }),
);
});
it('throws on non-ok response', async () => {
globalThis.fetch = mockFetchText('not found', false);
await expect(explorerCat('QmFile')).rejects.toThrow();
});
});
describe('explorerStat', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('POSTs to explorer/stat endpoint', async () => {
globalThis.fetch = mockFetch({ Size: 1234, Type: 'file' });
const result = await explorerStat('QmFile');
expect(result).toEqual({ Size: 1234, Type: 'file' });
});
});
describe('listIPNSKeys', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('transforms Keys array to IPNSKey[]', async () => {
globalThis.fetch = mockFetch({
Keys: [
{ Name: 'self', Id: 'k51...' },
{ Name: 'mykey', Id: 'k52...' },
],
});
const result = await listIPNSKeys();
expect(result).toEqual([
{ name: 'self', id: 'k51...' },
{ name: 'mykey', id: 'k52...' },
]);
});
it('returns empty array when no Keys', async () => {
globalThis.fetch = mockFetch({});
const result = await listIPNSKeys();
expect(result).toEqual([]);
});
it('handles missing fields', async () => {
globalThis.fetch = mockFetch({ Keys: [{ Id: 'k51...' }, { Name: 'onlyname' }] });
const result = await listIPNSKeys();
expect(result).toEqual([
{ name: '', id: 'k51...' },
{ name: 'onlyname', id: '' },
]);
});
});
describe('ipnsPublish', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('POSTs with cid and default key', async () => {
globalThis.fetch = mockFetch({ name: 'self', value: '/ipfs/QmX' });
const result = await ipnsPublish('QmX');
expect(result).toEqual({ name: 'self', value: '/ipfs/QmX' });
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/ipns/publish?arg=QmX&key=self',
expect.objectContaining({ method: 'POST' }),
);
});
it('uses custom key when provided', async () => {
globalThis.fetch = mockFetch({ name: 'mykey', value: '/ipfs/QmY' });
const result = await ipnsPublish('QmY', 'mykey');
expect(result).toEqual({ name: 'mykey', value: '/ipfs/QmY' });
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/ipns/publish?arg=QmY&key=mykey',
expect.anything(),
);
});
});
describe('ipnsResolve', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('returns Path from response', async () => {
globalThis.fetch = mockFetch({ Path: '/ipfs/QmResolved' });
const result = await ipnsResolve('k51...');
expect(result).toBe('/ipfs/QmResolved');
});
it('returns empty string when no Path', async () => {
globalThis.fetch = mockFetch({});
const result = await ipnsResolve('k51...');
expect(result).toBe('');
});
});
describe('ipnsGenKey', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('POSTs to gen endpoint', async () => {
globalThis.fetch = mockFetch({ name: 'newkey', id: 'k51...' });
const result = await ipnsGenKey('newkey');
expect(result).toEqual({ name: 'newkey', id: 'k51...' });
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/ipns/keys/gen/newkey',
expect.objectContaining({ method: 'POST' }),
);
});
});
describe('getRepoStats', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('maps RepoSize/StorageMax etc.', async () => {
globalThis.fetch = mockFetch({
RepoSize: 1000000,
StorageMax: 10000000000,
NumObjects: 500,
RepoPath: '/ipfs/repo',
Version: '0.18.0',
});
const result = await getRepoStats();
expect(result).toEqual({
repoSize: 1000000,
storageMax: 10000000000,
numObjects: 500,
repoPath: '/ipfs/repo',
version: '0.18.0',
});
});
it('defaults missing fields to 0 or empty', async () => {
globalThis.fetch = mockFetch({});
const result = await getRepoStats();
expect(result).toEqual({
repoSize: 0,
storageMax: 0,
numObjects: 0,
repoPath: '',
version: '',
});
});
});
describe('getBWStats', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('maps TotalIn/TotalOut etc.', async () => {
globalThis.fetch = mockFetch({
TotalIn: 5000,
TotalOut: 3000,
RateIn: 100,
RateOut: 50,
});
const result = await getBWStats();
expect(result).toEqual({
totalIn: 5000,
totalOut: 3000,
rateIn: 100,
rateOut: 50,
});
});
it('defaults missing fields to 0', async () => {
globalThis.fetch = mockFetch({});
const result = await getBWStats();
expect(result).toEqual({
totalIn: 0,
totalOut: 0,
rateIn: 0,
rateOut: 0,
});
});
});
describe('checkHealth', () => {
beforeEach(() => { globalThis.fetch = vi.fn(); });
afterEach(() => vi.restoreAllMocks());
it('returns status and node info', async () => {
globalThis.fetch = mockFetch({ status: 'ok', node: 'online' });
const result = await checkHealth();
expect(result).toEqual({ status: 'ok', node: 'online' });
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/health',
expect.objectContaining({ credentials: 'include' }),
);
});
});
+57 -12
View File
@@ -1,17 +1,29 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { formatBytes, gatewayLink, truncateCid } from '../helpers'; import { formatBytes, gatewayLink, gatewayUrl, truncateCid } from '../helpers';
describe('formatBytes', () => { describe('formatBytes', () => {
it('formats 0 bytes', () => { it('formats 0 as "0 B"', () => {
expect(formatBytes(0)).toBe('0 B'); expect(formatBytes(0)).toBe('0 B');
}); });
it('formats bytes without decimal', () => { it('formats bigint 0n as "0 B"', () => {
expect(formatBytes(512)).toBe('512 B'); expect(formatBytes(0n)).toBe('0 B');
});
it('formats bytes >= 10 as integer', () => {
expect(formatBytes(500)).toBe('500 B');
});
it('formats bytes < 10 with one decimal', () => {
expect(formatBytes(5)).toBe('5.0 B');
}); });
it('formats KB with one decimal when < 10', () => { it('formats KB with one decimal when < 10', () => {
expect(formatBytes(1536)).toBe('1.5 KB'); expect(formatBytes(1024)).toBe('1.0 KB');
});
it('formats 1500 bytes as 1.5 KB', () => {
expect(formatBytes(1500)).toBe('1.5 KB');
}); });
it('formats KB as integer when >= 10', () => { it('formats KB as integer when >= 10', () => {
@@ -22,23 +34,36 @@ describe('formatBytes', () => {
expect(formatBytes(1048576)).toBe('1.0 MB'); expect(formatBytes(1048576)).toBe('1.0 MB');
}); });
it('formats 3MB as 3.0 MB', () => {
expect(formatBytes(3145728)).toBe('3.0 MB');
});
it('formats large MB as integer when >= 10', () => {
expect(formatBytes(11534336)).toBe('11 MB');
});
it('formats GB with one decimal when < 10', () => { it('formats GB with one decimal when < 10', () => {
expect(formatBytes(1073741824)).toBe('1.0 GB'); expect(formatBytes(1073741824)).toBe('1.0 GB');
}); });
it('formats 2GB as 2.0 GB', () => {
expect(formatBytes(2147483648)).toBe('2.0 GB');
});
it('formats TB with one decimal when < 10', () => { it('formats TB with one decimal when < 10', () => {
expect(formatBytes(1099511627776)).toBe('1.0 TB'); expect(formatBytes(1099511627776)).toBe('1.0 TB');
}); });
it('formats large value as integer when >= 10', () => { it('handles bigint input', () => {
// 10+ MB → integer, no decimal expect(formatBytes(500n)).toBe('500 B');
expect(formatBytes(11534336)).toBe('11 MB');
}); });
it('handles negative values gracefully', () => { it('handles bigint zero', () => {
const result = formatBytes(-100); expect(formatBytes(0n)).toBe('0 B');
expect(result).toBeTruthy(); });
expect(typeof result).toBe('string');
it('handles bigint large value', () => {
expect(formatBytes(1073741824n)).toBe('1.0 GB');
}); });
}); });
@@ -48,11 +73,27 @@ describe('gatewayLink', () => {
expect(gatewayLink(cid)).toBe('https://ipfs.maos.dedyn.io/QmTest123'); expect(gatewayLink(cid)).toBe('https://ipfs.maos.dedyn.io/QmTest123');
}); });
it('handles bafy CIDs', () => {
const cid = 'bafybeigdyrzt5xxyjaz6k3w3h3y7a7q7q7q7q7q7q7q7q7q7q7q7q7q';
expect(gatewayLink(cid)).toBe('https://ipfs.maos.dedyn.io/bafybeigdyrzt5xxyjaz6k3w3h3y7a7q7q7q7q7q7q7q7q7q7q7q7q7q');
});
it('handles empty string', () => { it('handles empty string', () => {
expect(gatewayLink('')).toBe('https://ipfs.maos.dedyn.io/'); expect(gatewayLink('')).toBe('https://ipfs.maos.dedyn.io/');
}); });
}); });
describe('gatewayUrl (alias for gatewayLink)', () => {
it('returns same result as gatewayLink', () => {
const cid = 'QmTest123';
expect(gatewayUrl(cid)).toBe(gatewayLink(cid));
});
it('is a reference to gatewayLink', () => {
expect(gatewayUrl).toBe(gatewayLink);
});
});
describe('truncateCid', () => { describe('truncateCid', () => {
it('returns full CID when shorter than chars', () => { it('returns full CID when shorter than chars', () => {
expect(truncateCid('abc', 12)).toBe('abc'); expect(truncateCid('abc', 12)).toBe('abc');
@@ -72,6 +113,10 @@ describe('truncateCid', () => {
expect(truncateCid('abcdef', 6)).toBe('abcdef'); expect(truncateCid('abcdef', 6)).toBe('abcdef');
}); });
it('uses default of 12 chars', () => {
expect(truncateCid('abcdefghijklm')).toBe('abcdefghijkl...');
});
it('handles empty string', () => { it('handles empty string', () => {
expect(truncateCid('')).toBe(''); expect(truncateCid('')).toBe('');
}); });
+339
View File
@@ -0,0 +1,339 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
getSettings,
saveSettings,
resetSettings,
getHistory,
addToHistory,
clearHistory,
removeMultipleFromHistory,
type PortalSettings,
type UploadRecord,
} from '../storage';
const DEFAULT_SETTINGS: PortalSettings = {
gatewayUrl: 'https://ipfs.maos.dedyn.io',
apiEndpoint: '',
storageMax: 30720,
refreshInterval: 15,
theme: 'dark',
allowedFileTypes: '',
maxFileSize: 100 * 1024 * 1024,
maxFiles: 20,
};
function createMockStore(): Record<string, string> {
return {};
}
let originalWindow: Window | undefined;
function setupLocalStorage(store: Record<string, string>) {
// Node environment has no window — set it so storage functions don't SSR-short-circuit
originalWindow = globalThis.window;
if (typeof globalThis.window === 'undefined') {
globalThis.window = {} as any;
}
globalThis.localStorage = {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, val: string) => { store[key] = val; }),
removeItem: vi.fn((key: string) => { delete store[key]; }),
clear: vi.fn(() => { for (const k of Object.keys(store)) delete store[k]; }),
get length() { return Object.keys(store).length; },
key: vi.fn((_: number) => null),
} as unknown as Storage;
}
function restoreWindow() {
if (originalWindow === undefined) {
delete (globalThis as any).window;
} else {
globalThis.window = originalWindow as Window & typeof globalThis;
}
}
describe('getSettings', () => {
let store: Record<string, string>;
beforeEach(() => {
store = createMockStore();
setupLocalStorage(store);
});
afterEach(() => {
vi.restoreAllMocks();
restoreWindow();
});
it('returns defaults when localStorage is empty', () => {
const result = getSettings();
expect(result).toEqual(DEFAULT_SETTINGS);
});
it('merges partial saved settings with defaults', () => {
store['ipfs-portal-settings'] = JSON.stringify({ theme: 'light', storageMax: 5000 });
const result = getSettings();
expect(result.theme).toBe('light');
expect(result.storageMax).toBe(5000);
expect(result.gatewayUrl).toBe(DEFAULT_SETTINGS.gatewayUrl);
expect(result.refreshInterval).toBe(DEFAULT_SETTINGS.refreshInterval);
});
it('returns defaults when stored JSON is corrupt', () => {
store['ipfs-portal-settings'] = '{broken json';
const result = getSettings();
expect(result).toEqual(DEFAULT_SETTINGS);
});
it('returns defaults when typeof window is undefined (SSR)', () => {
const origWindow = globalThis.window;
(globalThis as any).window = undefined;
const result = getSettings();
expect(result).toEqual(DEFAULT_SETTINGS);
expect(localStorage.getItem).not.toHaveBeenCalled();
(globalThis as any).window = origWindow;
});
});
describe('saveSettings', () => {
let store: Record<string, string>;
beforeEach(() => {
store = createMockStore();
setupLocalStorage(store);
});
afterEach(() => {
vi.restoreAllMocks();
restoreWindow();
});
it('persists partial update to localStorage', () => {
const result = saveSettings({ theme: 'light', refreshInterval: 30 });
expect(result.theme).toBe('light');
expect(result.refreshInterval).toBe(30);
expect(result.gatewayUrl).toBe(DEFAULT_SETTINGS.gatewayUrl);
const saved = JSON.parse(store['ipfs-portal-settings']);
expect(saved.theme).toBe('light');
expect(saved.refreshInterval).toBe(30);
});
it('merges with existing saved settings', () => {
store['ipfs-portal-settings'] = JSON.stringify({ theme: 'light', storageMax: 5000 });
const result = saveSettings({ refreshInterval: 60 });
expect(result.theme).toBe('light');
expect(result.storageMax).toBe(5000);
expect(result.refreshInterval).toBe(60);
});
it('returns merged result (not just partial)', () => {
const result = saveSettings({ maxFiles: 5 });
expect(result.maxFiles).toBe(5);
expect(result.gatewayUrl).toBe(DEFAULT_SETTINGS.gatewayUrl);
expect(result.theme).toBe(DEFAULT_SETTINGS.theme);
});
});
describe('resetSettings', () => {
let store: Record<string, string>;
beforeEach(() => {
store = createMockStore();
setupLocalStorage(store);
});
afterEach(() => {
vi.restoreAllMocks();
restoreWindow();
});
it('removes settings key from localStorage', () => {
store['ipfs-portal-settings'] = JSON.stringify({ theme: 'light' });
resetSettings();
expect(store['ipfs-portal-settings']).toBeUndefined();
});
it('returns default settings', () => {
const result = resetSettings();
expect(result).toEqual(DEFAULT_SETTINGS);
});
});
describe('getHistory', () => {
let store: Record<string, string>;
beforeEach(() => {
store = createMockStore();
setupLocalStorage(store);
});
afterEach(() => {
vi.restoreAllMocks();
restoreWindow();
});
it('returns empty array when no history stored', () => {
expect(getHistory()).toEqual([]);
});
it('parses valid JSON array', () => {
const records: UploadRecord[] = [
{ cid: 'Qm1', name: 'a.txt', size: 100, date: '2025-01-01', method: 'free' },
];
store['ipfs-portal-history'] = JSON.stringify(records);
expect(getHistory()).toEqual(records);
});
it('handles corrupt JSON by removing key and returning empty', () => {
store['ipfs-portal-history'] = 'not json';
const result = getHistory();
expect(result).toEqual([]);
expect(store['ipfs-portal-history']).toBeUndefined();
});
it('handles non-array JSON by removing key and returning empty', () => {
store['ipfs-portal-history'] = JSON.stringify({ cid: 'Qm1' });
const result = getHistory();
expect(result).toEqual([]);
expect(store['ipfs-portal-history']).toBeUndefined();
});
it('returns empty array when typeof window is undefined (SSR)', () => {
const origWindow = globalThis.window;
(globalThis as any).window = undefined;
const result = getHistory();
expect(result).toEqual([]);
expect(localStorage.getItem).not.toHaveBeenCalled();
(globalThis as any).window = origWindow;
});
});
describe('addToHistory', () => {
let store: Record<string, string>;
beforeEach(() => {
store = createMockStore();
setupLocalStorage(store);
});
afterEach(() => {
vi.restoreAllMocks();
restoreWindow();
});
it('adds record to front of history', () => {
const record: UploadRecord = { cid: 'Qm1', name: 'a.txt', size: 100, date: '2025-01-01', method: 'free' };
const result = addToHistory(record);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(record);
});
it('deduplicates by cid + name', () => {
const existing: UploadRecord = { cid: 'Qm1', name: 'a.txt', size: 100, date: '2025-01-01', method: 'free' };
store['ipfs-portal-history'] = JSON.stringify([existing]);
const duplicate: UploadRecord = { cid: 'Qm1', name: 'a.txt', size: 200, date: '2025-02-01', method: 'eth' };
const result = addToHistory(duplicate);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(duplicate);
});
it('keeps non-duplicate entries', () => {
const existing: UploadRecord = { cid: 'Qm1', name: 'a.txt', size: 100, date: '2025-01-01', method: 'free' };
store['ipfs-portal-history'] = JSON.stringify([existing]);
const newRecord: UploadRecord = { cid: 'Qm2', name: 'b.txt', size: 200, date: '2025-02-01', method: 'eth' };
const result = addToHistory(newRecord);
expect(result).toHaveLength(2);
expect(result[0]).toEqual(newRecord);
expect(result[1]).toEqual(existing);
});
it('trims to 500 entries', () => {
const manyRecords: UploadRecord[] = Array.from({ length: 500 }, (_, i) => ({
cid: `Qm${i}`, name: `${i}.txt`, size: i, date: `2025-01-${String(i + 1).padStart(2, '0')}`, method: 'free' as const,
}));
store['ipfs-portal-history'] = JSON.stringify(manyRecords);
const newRecord: UploadRecord = { cid: 'QmNew', name: 'new.txt', size: 999, date: '2025-06-01', method: 'quick' };
const result = addToHistory(newRecord);
expect(result).toHaveLength(500);
expect(result[0]).toEqual(newRecord);
// The last entry (index 499) should be the 499th from original (since we sliced 0..500)
expect(result[result.length - 1].cid).toBe('Qm498');
});
});
describe('clearHistory', () => {
let store: Record<string, string>;
beforeEach(() => {
store = createMockStore();
setupLocalStorage(store);
});
afterEach(() => {
vi.restoreAllMocks();
restoreWindow();
});
it('removes history key from localStorage', () => {
store['ipfs-portal-history'] = JSON.stringify([{ cid: 'Qm1', name: 'a.txt', size: 100, date: '2025-01-01', method: 'free' }]);
clearHistory();
expect(store['ipfs-portal-history']).toBeUndefined();
});
});
describe('removeMultipleFromHistory', () => {
let store: Record<string, string>;
beforeEach(() => {
store = createMockStore();
setupLocalStorage(store);
});
afterEach(() => {
vi.restoreAllMocks();
restoreWindow();
});
it('removes matching entries by cid+date combo', () => {
const records: UploadRecord[] = [
{ cid: 'QmA', name: 'a.txt', size: 100, date: '2025-01-01', method: 'free' },
{ cid: 'QmB', name: 'b.txt', size: 200, date: '2025-02-01', method: 'eth' },
{ cid: 'QmC', name: 'c.txt', size: 300, date: '2025-03-01', method: 'token' },
];
store['ipfs-portal-history'] = JSON.stringify(records);
const result = removeMultipleFromHistory([
{ cid: 'QmA', date: '2025-01-01' },
{ cid: 'QmC', date: '2025-03-01' },
]);
expect(result).toHaveLength(1);
expect(result[0].cid).toBe('QmB');
});
it('keeps all entries when no ids match', () => {
const records: UploadRecord[] = [
{ cid: 'QmA', name: 'a.txt', size: 100, date: '2025-01-01', method: 'free' },
];
store['ipfs-portal-history'] = JSON.stringify(records);
const result = removeMultipleFromHistory([{ cid: 'QmX', date: '2025-99-99' }]);
expect(result).toHaveLength(1);
});
it('persists filtered result to localStorage', () => {
const records: UploadRecord[] = [
{ cid: 'QmA', name: 'a.txt', size: 100, date: '2025-01-01', method: 'free' },
{ cid: 'QmB', name: 'b.txt', size: 200, date: '2025-02-01', method: 'eth' },
];
store['ipfs-portal-history'] = JSON.stringify(records);
removeMultipleFromHistory([{ cid: 'QmA', date: '2025-01-01' }]);
const saved = JSON.parse(store['ipfs-portal-history']);
expect(saved).toHaveLength(1);
expect(saved[0].cid).toBe('QmB');
});
});
+4 -3
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { formatWeiToETH, formatBytes, CHAIN_IDS } from '../wallet'; import { formatWeiToETH, CHAIN_IDS } from '../wallet';
import { formatBytes } from '../helpers';
describe('formatWeiToETH', () => { describe('formatWeiToETH', () => {
it('formats 0 wei', () => { it('formats 0 wei', () => {
@@ -31,7 +32,7 @@ describe('formatWeiToETH', () => {
}); });
}); });
describe('formatBytes (wallet)', () => { describe('formatBytes (helpers)', () => {
it('formats 0 bytes', () => { it('formats 0 bytes', () => {
expect(formatBytes(0n)).toBe('0 B'); expect(formatBytes(0n)).toBe('0 B');
}); });
@@ -49,7 +50,7 @@ describe('formatBytes (wallet)', () => {
}); });
it('formats GB', () => { it('formats GB', () => {
expect(formatBytes(2n * 1024n * 1024n * 1024n)).toBe('2.00 GB'); expect(formatBytes(2n * 1024n * 1024n * 1024n)).toBe('2.0 GB');
}); });
it('accepts number input', () => { it('accepts number input', () => {
+5 -1
View File
@@ -84,7 +84,11 @@ export async function uploadFile(file: File): Promise<{ cid: string; size: numbe
body: form, body: form,
credentials: 'include', credentials: 'include',
}); });
if (!res.ok) throw new Error(`Upload failed: ${res.statusText}`); if (!res.ok) {
let errMsg = res.statusText;
try { const d = await res.json(); errMsg = d.error || d.Message || errMsg; } catch {}
throw new Error(`Upload failed: ${errMsg}`);
}
const data = await res.json(); const data = await res.json();
// Kubo API returns { Hash, Size, Name }, normalize to { cid, size } // Kubo API returns { Hash, Size, Name }, normalize to { cid, size }
return { return {
+44
View File
@@ -0,0 +1,44 @@
/* ── IPFS Portal API — Client / shared types ── */
export const API_BASE = ''; // nginx proxy /api/* → backend
/* ── Shared types ── */
export interface IPFSNodeInfo {
version: string;
peerId: string;
peers: number;
storageUsed: string;
storageMax: string;
uptime: string;
}
export interface RepoStats {
repoSize: number;
storageMax: number;
numObjects: number;
repoPath: string;
version: string;
}
export interface BWStats {
totalIn: number;
totalOut: number;
rateIn: number;
rateOut: number;
}
/* ── Generic fetch wrapper ── */
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}/api${path}`, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
credentials: 'include',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 200)}`);
}
return res.json();
}
+30
View File
@@ -0,0 +1,30 @@
/* ── IPFS Portal API — IPFS Explorer ── */
import { apiFetch, API_BASE } from './client';
export interface IPFSEntry {
name: string;
type: 'file' | 'dir';
size: number;
hash: string;
}
export async function explorerLs(cid: string): Promise<IPFSEntry[]> {
return apiFetch(`/explorer/ls/${encodeURIComponent(cid)}`, { method: 'POST' });
}
export async function explorerCat(cid: string): Promise<string> {
const res = await fetch(`${API_BASE}/api/explorer/cat/${encodeURIComponent(cid)}`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 200)}`);
}
return res.text();
}
export async function explorerStat(cid: string): Promise<Record<string, unknown>> {
return apiFetch(`/explorer/stat/${encodeURIComponent(cid)}`, { method: 'POST' });
}
+28
View File
@@ -0,0 +1,28 @@
/* ── IPFS Portal API — Files ── */
import { apiFetch, API_BASE } from './client';
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) {
let errMsg = res.statusText;
try { const d = await res.json(); errMsg = d.error || d.Message || errMsg; } catch {}
throw new Error(`Upload failed: ${errMsg}`);
}
const data = await res.json();
// Kubo API returns { Hash, Size, Name }, normalize to { cid, size }
return {
cid: data.cid || data.Hash || data.hash || '',
size: data.size || data.Size || 0,
};
}
export async function listFiles(): Promise<{ name: string; cid: string; size: number; modified: string }[]> {
return apiFetch('/files');
}
+52
View File
@@ -0,0 +1,52 @@
/* ── IPFS Portal API — Gateway / Node / Health ── */
import { apiFetch, type IPFSNodeInfo, type RepoStats, type BWStats } from './client';
/* ── Node info ── */
export async function getNodeInfo(): Promise<IPFSNodeInfo> {
return apiFetch('/node/info');
}
export async function getPeers(): Promise<{ id: string; addr: string; latency: string }[]> {
const raw = await apiFetch<{ Peers?: { Peer: string; Addr: string; Latency: string }[] }>('/peers');
return (raw.Peers || []).map((p: { Peer: string; Addr: string; Latency: string }) => ({
id: p.Peer,
addr: p.Addr,
latency: p.Latency || '—',
}));
}
/* ── Repo / Bandwidth Stats ── */
export async function getRepoStats(): Promise<RepoStats> {
const raw = await apiFetch<{
RepoSize?: number; StorageMax?: number; NumObjects?: number;
RepoPath?: string; Version?: string;
}>('/repo/stats');
return {
repoSize: raw.RepoSize ?? 0,
storageMax: raw.StorageMax ?? 0,
numObjects: raw.NumObjects ?? 0,
repoPath: raw.RepoPath ?? '',
version: raw.Version ?? '',
};
}
export async function getBWStats(): Promise<BWStats> {
const raw = await apiFetch<{
TotalIn?: number; TotalOut?: number; RateIn?: number; RateOut?: number;
}>('/bw/stats');
return {
totalIn: raw.TotalIn ?? 0,
totalOut: raw.TotalOut ?? 0,
rateIn: raw.RateIn ?? 0,
rateOut: raw.RateOut ?? 0,
};
}
/* ── Health ── */
export async function checkHealth(): Promise<{ status: string; node: string }> {
return apiFetch('/health');
}
+9
View File
@@ -0,0 +1,9 @@
/* ── Barrel: IPFS Portal API ── */
export * from './client';
export * from './gateway';
export * from './pins';
export * from './files';
export * from './users';
export * from './explorer';
export * from './ipns';
+32
View File
@@ -0,0 +1,32 @@
/* ── IPFS Portal API — IPNS ── */
import { apiFetch } from './client';
export interface IPNSKey {
name: string;
id: string;
}
export async function listIPNSKeys(): Promise<IPNSKey[]> {
const res = await apiFetch<{ Keys: { Name?: string; Id?: string }[] }>('/ipns/keys');
if (!res.Keys) return [];
return res.Keys.map((k: { Name?: string; Id?: string }) => ({
name: k.Name ?? '',
id: k.Id ?? '',
}));
}
export async function ipnsPublish(cid: string, key = 'self'): Promise<{ name: string; value: string }> {
return apiFetch(`/ipns/publish?arg=${encodeURIComponent(cid)}&key=${encodeURIComponent(key)}`, {
method: 'POST',
});
}
export async function ipnsResolve(name: string): Promise<string> {
const res = await apiFetch<{ Path: string }>(`/ipns/resolve/${encodeURIComponent(name)}`);
return res.Path || '';
}
export async function ipnsGenKey(name: string): Promise<IPNSKey> {
return apiFetch(`/ipns/keys/gen/${encodeURIComponent(name)}`, { method: 'POST' });
}
+32
View File
@@ -0,0 +1,32 @@
/* ── IPFS Portal API — Pins ── */
import { apiFetch } from './client';
export interface IPFSPin {
cid: string;
name: string;
size: number;
created: string;
}
export async function listPins(): Promise<IPFSPin[]> {
const raw = await apiFetch<{ Keys?: Record<string, { Type: string }> }>('/pins');
if (!raw.Keys) return [];
return Object.entries(raw.Keys).map(([cid, info]) => ({
cid,
name: '',
size: 0,
created: info.Type || '',
}));
}
export async function addPin(cid: string, name?: string): Promise<{ cid: string }> {
return apiFetch('/pins', {
method: 'POST',
body: JSON.stringify({ cid, name }),
});
}
export async function removePin(cid: string): Promise<void> {
await apiFetch(`/pins/${cid}`, { method: 'DELETE' });
}

Some files were not shown because too many files have changed in this diff Show More